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)); + } +}