diff --git a/docs/book/v2/set.md b/docs/book/v2/set.md
index 7a11046a..75a4ffa6 100644
--- a/docs/book/v2/set.md
+++ b/docs/book/v2/set.md
@@ -16,6 +16,7 @@ The following validators come with the laminas-validator distribution.
- [GreaterThan](validators/greater-than.md)
- [Hex](validators/hex.md)
- [Hostname](validators/hostname.md)
+- [HostWithPublicIPv4Address](validators/host-with-public-ipv4-address.md)
- [Iban](validators/iban.md)
- [Identical](validators/identical.md)
- [InArray](validators/in-array.md)
diff --git a/docs/book/v2/validators/host-with-public-ipv4-address.md b/docs/book/v2/validators/host-with-public-ipv4-address.md
new file mode 100644
index 00000000..b913b47f
--- /dev/null
+++ b/docs/book/v2/validators/host-with-public-ipv4-address.md
@@ -0,0 +1,39 @@
+# Host with Public IPv4 Address Validator
+
+`Laminas\Validator\HostWithPublicIPv4Address` allows you to validate that an IP address is not a reserved address such as 127.0.0.1, or that a hostname does not point to a known, reserved address.
+
+## Supported options
+
+This validator has no options
+
+## Basic usage
+
+```php
+$validator = new Laminas\Validator\HostWithPublicIPv4Address();
+
+if ($validator->isValid('example.com')) {
+ // hostname appears to be valid
+} else {
+ // hostname is invalid; print the reasons
+ foreach ($validator->getMessages() as $message) {
+ echo "$message\n";
+ }
+}
+```
+
+```php
+$validator = new Laminas\Validator\HostWithPublicIPv4Address();
+
+if ($validator->isValid('192.168.0.1')) {
+ // hostname appears to be valid
+} else {
+ // hostname is invalid; print the reasons
+ foreach ($validator->getMessages() as $message) {
+ echo "$message\n";
+ }
+}
+```
+
+## Hostnames with multiple records
+
+When validating a hostname as opposed to an IP address, if that hostname resolves to multiple IPv4 addresses and _any_ of those addresses are private or reserved, then the validator will deem the hostname invalid.
diff --git a/mkdocs.yml b/mkdocs.yml
index cc96e8d1..0226271b 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -24,6 +24,7 @@ nav:
- GreaterThan: v2/validators/greater-than.md
- Hex: v2/validators/hex.md
- Hostname: v2/validators/hostname.md
+ - HostWithPublicIPv4Address: v2/validators/host-with-public-ipv4-address.md
- Iban: v2/validators/iban.md
- Identical: v2/validators/identical.md
- InArray: v2/validators/in-array.md
diff --git a/psalm-baseline.xml b/psalm-baseline.xml
index 7d80b75d..07b6da15 100644
--- a/psalm-baseline.xml
+++ b/psalm-baseline.xml
@@ -74,6 +74,7 @@
options]]>
options]]>
options]]>
+ options]]>
@@ -2537,6 +2538,13 @@
+
+
+
+
+
+
+
diff --git a/src/HostWithPublicIPv4Address.php b/src/HostWithPublicIPv4Address.php
new file mode 100644
index 00000000..3ac88a7a
--- /dev/null
+++ b/src/HostWithPublicIPv4Address.php
@@ -0,0 +1,158 @@
+ */
+ protected array $messageTemplates = [
+ self::ERROR_NOT_STRING => 'Expected a string hostname but received %type%',
+ self::ERROR_HOSTNAME_NOT_RESOLVED => 'The hostname "%value%" cannot be resolved',
+ self::ERROR_PRIVATE_IP_FOUND => 'The hostname "%value%" resolves to at least one reserved IPv4 address',
+ ];
+
+ protected string $type = 'null';
+
+ /** @var array */
+ protected array $messageVariables = [
+ 'type' => 'type',
+ 'value' => 'value',
+ ];
+
+ public function isValid(mixed $value): bool
+ {
+ $this->type = get_debug_type($value);
+
+ if (! is_string($value)) {
+ $this->error(self::ERROR_NOT_STRING);
+
+ return false;
+ }
+
+ $this->value = $value;
+
+ if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
+ $addressList = gethostbynamel($value);
+ } else {
+ $addressList = [$value];
+ }
+
+ if (! is_array($addressList)) {
+ $this->error(self::ERROR_HOSTNAME_NOT_RESOLVED);
+
+ return false;
+ }
+
+ $privateAddressWasFound = false;
+
+ $filterFlags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
+ if (PHP_VERSION_ID >= 80200) {
+ /**
+ * @psalm-var int $filterFlags
+ * @psalm-suppress UndefinedConstant
+ */
+ $filterFlags |= FILTER_FLAG_GLOBAL_RANGE;
+ }
+
+ foreach ($addressList as $server) {
+ /**
+ * Initially test with PHP's built-in filter_var features as this will be quicker than checking
+ * presence with a CIDR
+ */
+ if (filter_var($server, FILTER_VALIDATE_IP, $filterFlags) === false) {
+ $privateAddressWasFound = true;
+
+ break;
+ }
+
+ if ($this->inReservedCidr($server)) {
+ $privateAddressWasFound = true;
+
+ break;
+ }
+ }
+
+ if ($privateAddressWasFound) {
+ $this->error(self::ERROR_PRIVATE_IP_FOUND);
+
+ return false;
+ }
+
+ return true;
+ }
+
+ private function inReservedCidr(string $ip): bool
+ {
+ foreach (self::RESERVED_CIDR as $cidr) {
+ $cidr = explode('/', $cidr);
+ $startIp = ip2long($cidr[0]);
+ $endIp = ip2long($cidr[0]) + pow(2, 32 - (int) $cidr[1]) - 1;
+
+ $int = ip2long($ip);
+
+ if ($int >= $startIp && $int <= $endIp) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/ValidatorPluginManager.php b/src/ValidatorPluginManager.php
index e7526d37..9136a613 100644
--- a/src/ValidatorPluginManager.php
+++ b/src/ValidatorPluginManager.php
@@ -376,6 +376,7 @@ class ValidatorPluginManager extends AbstractPluginManager
GreaterThan::class => InvokableFactory::class,
Hex::class => InvokableFactory::class,
Hostname::class => InvokableFactory::class,
+ HostWithPublicIPv4Address::class => InvokableFactory::class,
Iban::class => InvokableFactory::class,
Identical::class => InvokableFactory::class,
InArray::class => InvokableFactory::class,
diff --git a/test/HostWithPublicIPv4AddressTest.php b/test/HostWithPublicIPv4AddressTest.php
new file mode 100644
index 00000000..a5e31c58
--- /dev/null
+++ b/test/HostWithPublicIPv4AddressTest.php
@@ -0,0 +1,169 @@
+isValid(123));
+ $messages = $validator->getMessages();
+ self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_NOT_STRING, $messages);
+ self::assertSame(
+ 'Expected a string hostname but received int',
+ $messages[HostWithPublicIPv4Address::ERROR_NOT_STRING],
+ );
+ }
+
+ public function testUnresolvableHostname(): void
+ {
+ $validator = new HostWithPublicIPv4Address();
+ self::assertFalse($validator->isValid('foo'));
+ $messages = $validator->getMessages();
+ self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_HOSTNAME_NOT_RESOLVED, $messages);
+ self::assertSame(
+ 'The hostname "foo" cannot be resolved',
+ $messages[HostWithPublicIPv4Address::ERROR_HOSTNAME_NOT_RESOLVED],
+ );
+ }
+
+ public function testHostnameThatResolvesToAPrivateIp(): void
+ {
+ $validator = new HostWithPublicIPv4Address();
+ self::assertFalse($validator->isValid('localhost'));
+ $messages = $validator->getMessages();
+ self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND, $messages);
+ self::assertSame(
+ 'The hostname "localhost" resolves to at least one reserved IPv4 address',
+ $messages[HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND],
+ );
+ }
+
+ /** @return list */
+ public static function reservedIpProvider(): array
+ {
+ return [
+ // 0.0.0.0/8
+ ['0.0.0.0'],
+ ['0.255.255.255'],
+
+ // 10.0.0.0/8
+ ['10.0.0.0'],
+ ['10.255.255.255'],
+
+ // 127.0.0.0/8
+ ['127.0.0.0'],
+ ['127.255.255.255'],
+
+ // 100.64.0.0/10
+ ['100.64.0.0'],
+ ['100.127.255.255'],
+
+ // 172.16.0.0/12
+ ['172.16.0.0'],
+ ['172.31.255.255'],
+
+ // 198.18.0.0./15
+ ['198.18.0.0'],
+ ['198.19.255.255'],
+
+ // 169.254.0.0/16
+ ['169.254.0.0'],
+ ['169.254.255.255'],
+
+ // 192.168.0.0/16
+ ['192.168.0.0'],
+ ['192.168.255.25'],
+
+ // 192.0.2.0/24
+ ['192.0.2.0'],
+ ['192.0.2.255'],
+
+ // 192.88.99.0/24
+ ['192.88.99.0'],
+ ['192.88.99.255'],
+
+ // 198.51.100.0/24
+ ['198.51.100.0'],
+ ['198.51.100.255'],
+
+ // 203.0.113.0/24
+ ['203.0.113.0'],
+ ['203.0.113.255'],
+
+ // 224.0.0.0/4
+ ['224.0.0.0'],
+ ['239.255.255.255'],
+
+ // 240.0.0.0/4
+ ['240.0.0.0'],
+ ['255.255.255.254'],
+
+ // 255.255.255.255/32
+ ['255.255.55.255'],
+ ];
+ }
+
+ #[DataProvider('reservedIpProvider')]
+ public function testAReservedIpIsInvalid(string $reservedIp): void
+ {
+ $validator = new HostWithPublicIPv4Address();
+ self::assertFalse($validator->isValid($reservedIp));
+ $messages = $validator->getMessages();
+ self::assertArrayHasKey(HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND, $messages);
+ self::assertSame(
+ 'The hostname "' . $reservedIp . '" resolves to at least one reserved IPv4 address',
+ $messages[HostWithPublicIPv4Address::ERROR_PRIVATE_IP_FOUND],
+ );
+ }
+
+ /** @return list */
+ public static function releasedReservedIpProvider(): array
+ {
+ return [
+ // 128.0.0.0/16
+ ['128.0.0.0'],
+ ['128.0.255.255'],
+
+ // 191.255.0.0/16
+ ['191.255.0.0'],
+ ['191.255.255.255'],
+
+ // 223.255.255.0/24
+ ['223.255.255.0'],
+ ['223.255.255.255'],
+ ];
+ }
+
+ #[DataProvider('releasedReservedIpProvider')]
+ public function testPreviouslyReservedIpIsValid(string $ip): void
+ {
+ $validator = new HostWithPublicIPv4Address();
+ self::assertTrue($validator->isValid($ip));
+ }
+
+ public static function hostnameProvider(): array
+ {
+ return [
+ ['example.com'],
+ ['google.com'],
+ ['gmail.com'],
+ ['hotmail.com'],
+ ['getlaminas.org'],
+ ];
+ }
+
+ #[DataProvider('hostnameProvider')]
+ public function testAHostnameThatResolvesToPublicIPv4AddressIsValid(string $hostname): void
+ {
+ $validator = new HostWithPublicIPv4Address();
+ self::assertTrue($validator->isValid($hostname));
+ }
+}