From 754fe72a3272afda1c9a18fafbd7e778a82f8082 Mon Sep 17 00:00:00 2001 From: George Steel Date: Fri, 14 Jun 2024 11:47:17 +0100 Subject: [PATCH 1/2] Add new Validator: `HostWithPublicIPv4Address` Whilst doing some refactoring for v3, I found that the Email Address validator validates IP addresses/hostnames for reserved IPs. In order to test this effectively, it makes more sense to implement this as a validator and compose that into the Email address validator - it is likely to prove useful to some users too in a standalone context. So, this validator is a pretty straightforward extraction of https://github.com/laminas/laminas-validator/blob/2.57.x/src/EmailAddress.php#L282-L344 The expectation is that the functionality in the Email validator will be replaced with this. Signed-off-by: George Steel --- docs/book/v2/set.md | 1 + .../host-with-public-ipv4-address.md | 39 +++++ mkdocs.yml | 1 + psalm-baseline.xml | 7 + src/HostWithPublicIPv4Address.php | 98 +++++++++++ src/ValidatorPluginManager.php | 1 + test/HostWithPublicIPv4AddressTest.php | 157 ++++++++++++++++++ 7 files changed, 304 insertions(+) create mode 100644 docs/book/v2/validators/host-with-public-ipv4-address.md create mode 100644 src/HostWithPublicIPv4Address.php create mode 100644 test/HostWithPublicIPv4AddressTest.php 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..aabd35ac 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -74,6 +74,7 @@ options]]> options]]> options]]> + options]]> @@ -2537,6 +2538,12 @@ + + + + + + diff --git a/src/HostWithPublicIPv4Address.php b/src/HostWithPublicIPv4Address.php new file mode 100644 index 00000000..f9236ef6 --- /dev/null +++ b/src/HostWithPublicIPv4Address.php @@ -0,0 +1,98 @@ + */ + 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 (! preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $value)) { + $addressList = gethostbynamel($value); + } else { + $addressList = [$value]; + } + + if (! is_array($addressList)) { + $this->error(self::ERROR_HOSTNAME_NOT_RESOLVED); + + return false; + } + + $privateAddressWasFound = false; + + // phpcs:disable Generic.Files.LineLength + foreach ($addressList as $server) { + // Search for 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8 + if ( + preg_match('/^(0|10|127)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server) + || + // Search for 100.64.0.0/10 + preg_match('/^100\.(6[0-4]|[7-9][0-9]|1[0-1][0-9]|12[0-7])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) + || + // Search for 172.16.0.0/12 + preg_match('/^172\.(1[6-9]|2[0-9]|3[0-1])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) + || + // Search for 198.18.0.0/15 + preg_match('/^198\.(1[8-9])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) + || + // Search for 169.254.0.0/16, 192.168.0.0/16 + preg_match('/^(169\.254|192\.168)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) + || + // Search for 192.0.2.0/24, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24 + preg_match('/^(192\.0\.2|192\.88\.99|198\.51\.100|203\.0\.113)\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))$/', $server) + || + // Search for 224.0.0.0/4, 240.0.0.0/4 + preg_match('/^(2(2[4-9]|[3-4][0-9]|5[0-5]))(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server) + ) { + $privateAddressWasFound = true; + + break; + } + } + + if ($privateAddressWasFound) { + $this->error(self::ERROR_PRIVATE_IP_FOUND); + + return false; + } + + return true; + } +} 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..8405ae1b --- /dev/null +++ b/test/HostWithPublicIPv4AddressTest.php @@ -0,0 +1,157 @@ +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 function testAHostnameThatResolvesToPublicIPv4AddressIsValid(): void + { + $validator = new HostWithPublicIPv4Address(); + self::assertTrue($validator->isValid('example.com')); + } +} From 2dc340c3ee08032715f5bdc84047240d6e751ba2 Mon Sep 17 00:00:00 2001 From: George Steel Date: Fri, 14 Jun 2024 14:08:07 +0100 Subject: [PATCH 2/2] Swap flawed regexes for checking ips within known reserved cidr's. Also make use of PHP's built-in filters for (assumed) improved performance. Signed-off-by: George Steel --- psalm-baseline.xml | 1 + src/HostWithPublicIPv4Address.php | 110 +++++++++++++++++++------ test/HostWithPublicIPv4AddressTest.php | 16 +++- 3 files changed, 100 insertions(+), 27 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index aabd35ac..07b6da15 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -2540,6 +2540,7 @@ + diff --git a/src/HostWithPublicIPv4Address.php b/src/HostWithPublicIPv4Address.php index f9236ef6..3ac88a7a 100644 --- a/src/HostWithPublicIPv4Address.php +++ b/src/HostWithPublicIPv4Address.php @@ -4,14 +4,60 @@ namespace Laminas\Validator; +use function explode; +use function filter_var; use function get_debug_type; use function gethostbynamel; +use function ip2long; use function is_array; use function is_string; -use function preg_match; +use function pow; + +use const FILTER_FLAG_GLOBAL_RANGE; +use const FILTER_FLAG_IPV4; +use const FILTER_FLAG_NO_PRIV_RANGE; +use const FILTER_FLAG_NO_RES_RANGE; +use const FILTER_VALIDATE_IP; +use const PHP_VERSION_ID; final class HostWithPublicIPv4Address extends AbstractValidator { + /** + * Reserved CIDRs are extracted from IANA with additions from Wikipedia + * + * @link https://www.iana.org/assignments/iana-ipv4-special-registry/iana-ipv4-special-registry.xhtml + * @link https://en.wikipedia.org/wiki/Reserved_IP_addresses + */ + private const RESERVED_CIDR = [ + '0.0.0.0/8', + '0.0.0.0/32', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.0.0/29', + '192.0.0.8/32', + '192.0.0.9/32', + '192.0.0.10/32', + '192.0.0.170/32', + '192.0.0.171/32', + '192.0.2.0/24', + '192.31.196.0/24', + '192.52.193.0/24', + '192.88.99.0/24', + '192.168.0.0/16', + '192.175.48.0/24', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', // Wikipedia + '233.252.0.0/24', // Wikipedia + '240.0.0.0/4', + '255.255.255.255/32', + ]; + public const ERROR_NOT_STRING = 'hostnameNotString'; public const ERROR_HOSTNAME_NOT_RESOLVED = 'hostnameNotResolved'; public const ERROR_PRIVATE_IP_FOUND = 'privateIpAddressFound'; @@ -43,7 +89,7 @@ public function isValid(mixed $value): bool $this->value = $value; - if (! preg_match('/^([0-9]{1,3}\.){3}[0-9]{1,3}$/', $value)) { + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) { $addressList = gethostbynamel($value); } else { $addressList = [$value]; @@ -57,30 +103,27 @@ public function isValid(mixed $value): bool $privateAddressWasFound = false; - // phpcs:disable Generic.Files.LineLength + $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) { - // Search for 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8 - if ( - preg_match('/^(0|10|127)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $server) - || - // Search for 100.64.0.0/10 - preg_match('/^100\.(6[0-4]|[7-9][0-9]|1[0-1][0-9]|12[0-7])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) - || - // Search for 172.16.0.0/12 - preg_match('/^172\.(1[6-9]|2[0-9]|3[0-1])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) - || - // Search for 198.18.0.0/15 - preg_match('/^198\.(1[8-9])(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) - || - // Search for 169.254.0.0/16, 192.168.0.0/16 - preg_match('/^(169\.254|192\.168)(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){2}$/', $server) - || - // Search for 192.0.2.0/24, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24 - preg_match('/^(192\.0\.2|192\.88\.99|198\.51\.100|203\.0\.113)\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))$/', $server) - || - // Search for 224.0.0.0/4, 240.0.0.0/4 - preg_match('/^(2(2[4-9]|[3-4][0-9]|5[0-5]))(\.([0-9]|[1-9][0-9]|1([0-9][0-9])|2([0-4][0-9]|5[0-5]))){3}$/', $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; @@ -95,4 +138,21 @@ public function isValid(mixed $value): bool 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/test/HostWithPublicIPv4AddressTest.php b/test/HostWithPublicIPv4AddressTest.php index 8405ae1b..a5e31c58 100644 --- a/test/HostWithPublicIPv4AddressTest.php +++ b/test/HostWithPublicIPv4AddressTest.php @@ -149,9 +149,21 @@ public function testPreviouslyReservedIpIsValid(string $ip): void self::assertTrue($validator->isValid($ip)); } - public function testAHostnameThatResolvesToPublicIPv4AddressIsValid(): void + 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('example.com')); + self::assertTrue($validator->isValid($hostname)); } }