From 3531498e348c33b62450e5381329ed2b8cf1412d Mon Sep 17 00:00:00 2001 From: i-just Date: Fri, 9 Oct 2026 09:02:47 +0200 Subject: [PATCH 1/6] guzzle redirect validation and pinning --- CHANGELOG.md | 5 +++ README.md | 33 +++++++++++++++++ composer.json | 4 +++ src/GuzzleMiddleware.php | 78 ++++++++++++++++++++++++++++++++++++++++ src/UrlValidator.php | 27 ++++++++++++++ 5 files changed, 147 insertions(+) create mode 100644 src/GuzzleMiddleware.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 5108b47..262144e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to `url-validator` will be documented in this file. +## 1.2.0 - unreleased + +- Added `UrlValidator::curlResolve()`. +- Added `GuzzleMiddleware`, which validates and pins every request a Guzzle client sends, including redirects. + ## 1.1.0 - 2026-07-06 - Added an `$options` argument to the `UrlValidator` constructor. ([#2](https://github.com/craftcms/url-validator/pull/2)) diff --git a/README.md b/README.md index 274d6b3..5688702 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,39 @@ See the codebase for the default values and expected types. $validator = new UrlValidator(options: ['ipv4FilterFlags' => FILTER_FLAG_NO_RES_RANGE]); ``` +### Pinning with curl + +Validating a URL and then letting curl resolve its hostname again leaves room for DNS rebinding. `curlResolve()` validates the URL and returns a `CURLOPT_RESOLVE` value that pins the hostname and port to the validated IP addresses. + +```php +$ch = curl_init($url); +curl_setopt($ch, CURLOPT_RESOLVE, $validator->curlResolve($url)); +``` + +### Using with Guzzle + +Requires `guzzlehttp/guzzle`. `GuzzleMiddleware` validates and pins every request a Guzzle client sends, including the ones sent while following redirects. A disallowed URL makes the request throw an `UrlValidationException`. + +```php +use CraftCms\UrlValidator\GuzzleMiddleware; +use GuzzleHttp\Client; +use GuzzleHttp\HandlerStack; + +$stack = HandlerStack::create(); +GuzzleMiddleware::push($stack, $validator); +$client = new Client(['handler' => $stack]); +``` + +If the client has already been created, you can add the middleware to its handler stack instead: + +```php +GuzzleMiddleware::attach($client, $validator); +``` + +> [!NOTE] +> - Pinning only works with Guzzle’s curl handler. The middleware turns the `stream` request option off, because the stream handler would ignore the pin. +> - When requests go through an HTTP proxy, the proxy resolves the hostname itself, so the pin doesn’t apply there. URLs are still validated, but the proxy should enforce its own egress rules. + ## Testing ```bash diff --git a/composer.json b/composer.json index 2887e70..ddb5c14 100644 --- a/composer.json +++ b/composer.json @@ -22,11 +22,15 @@ "ext-filter": "*" }, "require-dev": { + "guzzlehttp/guzzle": "^7.0", "laravel/pint": "^1.29", "nunomaduro/collision": "^8.1", "phpstan/phpstan": "^2.0", "pestphp/pest": "^3.0" }, + "suggest": { + "guzzlehttp/guzzle": "Required to use GuzzleMiddleware" + }, "autoload": { "psr-4": { "CraftCms\\UrlValidator\\": "src/" diff --git a/src/GuzzleMiddleware.php b/src/GuzzleMiddleware.php new file mode 100644 index 0000000..22958e2 --- /dev/null +++ b/src/GuzzleMiddleware.php @@ -0,0 +1,78 @@ + + */ +class GuzzleMiddleware +{ + /** + * @var string The name the middleware is registered under in a handler stack. + */ + public const NAME = 'url_validator'; + + /** + * Returns a middleware that validates every request’s URL and pins its hostname to the validated IPs. + */ + public static function validateRequests(?UrlValidator $validator = null): callable + { + $validator = $validator ?? new UrlValidator; + + return static function (callable $handler) use ($validator): callable { + return static function (RequestInterface $request, array $options) use ($handler, $validator) { + $url = (string) $request->getUri(); + + if (defined('CURLOPT_RESOLVE')) { + // Pin the hostname/port to the IPs we just validated. + $options['curl'][CURLOPT_RESOLVE] = $validator->curlResolve($url); + // with `stream` on, Guzzle uses its stream handler, which ignores curl options and so the pin + $options['stream'] = false; + } else { + // the stream handler can’t pin, so only validate + $validator->validate($url); + } + + return $handler($request, $options); + }; + }; + } + + /** + * Adds the middleware to the end of the handler stack, so it runs for every redirect hop too. + */ + public static function push(HandlerStack $handlerStack, ?UrlValidator $validator = null): void + { + // pushed to the end of the stack, so it sits below the redirect middleware + // and gets called for each request sent while following redirects + $handlerStack->remove(self::NAME); + $handlerStack->push(self::validateRequests($validator), self::NAME); + } + + /** + * Adds the middleware to the client’s handler stack. Returns false if the client doesn’t use one. + * + * This relies on `Client::getConfig()`, which is deprecated in Guzzle 7, so prefer [[push()]] + * when you create the handler stack yourself. + */ + public static function attach(Client $client, ?UrlValidator $validator = null): bool + { + $handlerStack = $client->getConfig('handler'); + + if (! $handlerStack instanceof HandlerStack) { + return false; + } + + self::push($handlerStack, $validator); + + return true; + } +} diff --git a/src/UrlValidator.php b/src/UrlValidator.php index b4377ff..d8239cb 100644 --- a/src/UrlValidator.php +++ b/src/UrlValidator.php @@ -141,6 +141,33 @@ public function validate(string $url): array return $ips; } + /** + * Validates a remote URL and returns a `CURLOPT_RESOLVE` value that pins its hostname and port to the validated IP addresses. + * + * @return string[] + * + * @throws UrlValidationException if the URL, or any IP it resolves to, is disallowed. + */ + public function curlResolve(string $url): array + { + $ips = $this->validate($url); + + $parts = parse_url($url); + $host = $parts['host'] ?? ''; + $scheme = strtolower($parts['scheme'] ?? ''); + + if ($host === '' || $scheme === '') { + throw new UrlValidationException("$url could not be pinned to its resolved IP addresses."); + } + + $port = $parts['port'] ?? ($scheme === 'https' ? 443 : 80); + + // IPv6 addresses need to be wrapped in brackets + $ips = array_map(fn (string $ip): string => str_contains($ip, ':') ? "[$ip]" : $ip, $ips); + + return ["$host:$port:".implode(',', $ips)]; + } + /** * Returns whether a URL’s scheme is allowed (only `http` and `https`). */ From 49c7b4e253c566af8d2f43921bd66d9e761643d2 Mon Sep 17 00:00:00 2001 From: i-just Date: Fri, 9 Oct 2026 09:31:30 +0200 Subject: [PATCH 2/6] mechanism for allowing private hosts without turning off no_priv_range filter for all urls --- CHANGELOG.md | 1 + README.md | 24 ++++++++++++ src/UrlValidator.php | 89 +++++++++++++++++++++++++++++++++++++++----- 3 files changed, 105 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 262144e..096270b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `url-validator` will be documented in this file. - Added `UrlValidator::curlResolve()`. - Added `GuzzleMiddleware`, which validates and pins every request a Guzzle client sends, including redirects. +- Added the `allowedPrivateHosts` option, which lets specific hostnames resolve to private IP addresses. ## 1.1.0 - 2026-07-06 diff --git a/README.md b/README.md index 5688702..8a7fc01 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The following options can also be configured by passing an array to the construc - `disallowedIpv4Ranges` - `ipv4FilterFlags` (the `FILTER_FLAG_IPV4` will always be added automatically) - `ipv6FilterFlags` (the `FILTER_FLAG_IPV6` will always be added automatically) +- `allowedPrivateHosts` (see [Allowing internal hosts](#allowing-internal-hosts)) See the codebase for the default values and expected types. @@ -98,6 +99,29 @@ See the codebase for the default values and expected types. $validator = new UrlValidator(options: ['ipv4FilterFlags' => FILTER_FLAG_NO_RES_RANGE]); ``` +### Allowing internal hosts + +Private IP addresses (e.g. `10.0.0.0/8`, `192.168.0.0/16`, `fd00::/8`) are rejected by default. If your app needs to reach internal services, for example in local development or between containers in Docker or Kubernetes, list the hostnames that may resolve to private addresses: + +```php +$validator = new UrlValidator(options: [ + 'allowedPrivateHosts' => [ + 'my-api.internal', + '*.ddev.site', + ], +]); +``` + +- An exact hostname matches only that host. +- A leading `*.` wildcard matches any subdomain, e.g. `*.site.testing.local` matches `api.site.testing.local` and `a.b.site.testing.local`, but not `site.testing.local` itself. +- `*` isn’t allowed anywhere else, and a bare `*` isn’t allowed at all. Invalid patterns throw an `InvalidArgumentException`. +- Only the private-range check is relaxed for these hosts. Loopback, link-local, reserved and cloud-metadata addresses, and disallowed hostnames such as `kubernetes.default.svc`, are still rejected. + +> [!WARNING] +> - Only list hostnames whose DNS you control. Never list wildcard-DNS services such as `nip.io` or `sslip.io`, which resolve to whatever IP address is in the name. +> - List individual services rather than whole internal domains. Every listed host is reachable by anyone who can make your app send a request to it. +> - Don’t let users edit this list (e.g. through a control panel). Keep it in code or config files. + ### Pinning with curl Validating a URL and then letting curl resolve its hostname again leaves room for DNS rebinding. `curlResolve()` validates the URL and returns a `CURLOPT_RESOLVE` value that pins the hostname and port to the validated IP addresses. diff --git a/src/UrlValidator.php b/src/UrlValidator.php index d8239cb..110ed80 100644 --- a/src/UrlValidator.php +++ b/src/UrlValidator.php @@ -76,11 +76,23 @@ class UrlValidator */ private int $ipv6FilterFlags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_IPV6; + /** + * @var string[] A list of hostnames that are allowed to resolve to private IP addresses. + * + * Each entry is either an exact hostname (`api.internal`) or a leading `*.` wildcard + * (`*.ddev.site`), which matches any subdomain but not the domain itself. + * Only the private-range check is relaxed for these hosts. Reserved, loopback, link-local, + * cloud-metadata and other disallowed addresses and hostnames are still rejected. + */ + private array $allowedPrivateHosts = []; + /** * @param callable(string):string[]|null $resolver A custom hostname resolver, primarily * useful for testing. Receives a hostname and should return its IP addresses. Defaults to * resolving against the system DNS via [[resolveHostIps()]]. - * @param array{allowedSchemes?:string[],disallowedHostnames?:string[],disallowedIpv4Addresses?:string[],disallowedIpv4Ranges?:array{0:string,1:int}[],ipv4FilterFlags?:int,ipv6FilterFlags?:int}|null $options Overrides for the default validation rules. Any key that’s omitted falls back to its default. `ipv4FilterFlags`/`ipv6FilterFlags` are combined with `FILTER_FLAG_IPV4`/`FILTER_FLAG_IPV6` respectively, so those don’t need to be included. + * @param array{allowedSchemes?:string[],disallowedHostnames?:string[],disallowedIpv4Addresses?:string[],disallowedIpv4Ranges?:array{0:string,1:int}[],ipv4FilterFlags?:int,ipv6FilterFlags?:int,allowedPrivateHosts?:string[]}|null $options Overrides for the default validation rules. Any key that’s omitted falls back to its default. `ipv4FilterFlags`/`ipv6FilterFlags` are combined with `FILTER_FLAG_IPV4`/`FILTER_FLAG_IPV6` respectively, so those don’t need to be included. + * + * @throws \InvalidArgumentException if an `allowedPrivateHosts` entry isn’t a valid pattern. */ public function __construct(?callable $resolver = null, ?array $options = null) { @@ -101,6 +113,13 @@ public function __construct(?callable $resolver = null, ?array $options = null) // if the options come from the user, ensure the ipv6 flag is always set $this->ipv6FilterFlags = $options['ipv6FilterFlags'] | FILTER_FLAG_IPV6; } + + if (isset($options['allowedPrivateHosts'])) { + $this->allowedPrivateHosts = array_map( + fn ($pattern): string => $this->normalizeAllowedPrivateHost($pattern), + $options['allowedPrivateHosts'], + ); + } } } @@ -126,6 +145,7 @@ public function validate(string $url): array } $host = (string) parse_url($url, PHP_URL_HOST); + $allowPrivate = $this->isAllowedPrivateHost($host); $ips = ($this->resolver)($host); if (empty($ips)) { @@ -133,7 +153,7 @@ public function validate(string $url): array } foreach ($ips as $ip) { - if (! $this->validateIp($ip)) { + if (! $this->validateIp($ip, $allowPrivate)) { throw new UrlValidationException("$url resolves to an invalid IP address."); } } @@ -221,8 +241,9 @@ public function validateHostname(string $url): bool * * Rejects private, reserved, loopback, link-local, CGNAT, and cloud-metadata * addresses, as well as IPv6 addresses that embed or tunnel an IPv4 address. + * Private addresses are allowed when `$allowPrivate` is true. */ - public function validateIp(string $ip): bool + public function validateIp(string $ip, bool $allowPrivate = false): bool { // Parse to the packed (binary) form so checks are done on the canonical // address rather than its textual representation — which has many @@ -236,13 +257,13 @@ public function validateIp(string $ip): bool } return match (strlen($packed)) { - 4 => $this->validateIpv4($ip), - 16 => $this->validateIpv6($ip, $packed), + 4 => $this->validateIpv4($ip, $allowPrivate), + 16 => $this->validateIpv6($ip, $packed, $allowPrivate), default => false, }; } - private function validateIpv4(string $ip): bool + private function validateIpv4(string $ip, bool $allowPrivate): bool { if (in_array($ip, $this->disallowedIpv4Addresses, true)) { return false; @@ -254,10 +275,12 @@ private function validateIpv4(string $ip): bool } } - return filter_var($ip, FILTER_VALIDATE_IP, $this->ipv4FilterFlags) !== false; + $flags = $allowPrivate ? $this->ipv4FilterFlags & ~FILTER_FLAG_NO_PRIV_RANGE : $this->ipv4FilterFlags; + + return filter_var($ip, FILTER_VALIDATE_IP, $flags) !== false; } - private function validateIpv6(string $ip, string $packed): bool + private function validateIpv6(string $ip, string $packed, bool $allowPrivate): bool { // Reject any IPv6 address that embeds or tunnels an IPv4 address. We never // legitimately need to fetch a file over one, and they’re a common way to @@ -281,7 +304,55 @@ private function validateIpv6(string $ip, string $packed): bool return false; } - return filter_var($ip, FILTER_VALIDATE_IP, $this->ipv6FilterFlags) !== false; + $flags = $allowPrivate ? $this->ipv6FilterFlags & ~FILTER_FLAG_NO_PRIV_RANGE : $this->ipv6FilterFlags; + + return filter_var($ip, FILTER_VALIDATE_IP, $flags) !== false; + } + + /** + * Returns whether a hostname is allowed to resolve to private IP addresses. + */ + private function isAllowedPrivateHost(string $host): bool + { + // normalize the same way validateHostname() does + $host = rtrim(strtolower($host), '.'); + + foreach ($this->allowedPrivateHosts as $pattern) { + if (str_starts_with($pattern, '*.')) { + // a wildcard matches any subdomain, but not the domain itself + if (str_ends_with($host, substr($pattern, 1))) { + return true; + } + } elseif ($host === $pattern) { + return true; + } + } + + return false; + } + + /** + * Normalizes an allowed private host pattern and makes sure it’s safe to match against. + * + * @throws \InvalidArgumentException if the pattern isn’t an exact hostname or a leading `*.` wildcard. + */ + private function normalizeAllowedPrivateHost(mixed $pattern): string + { + if (! is_string($pattern)) { + throw new \InvalidArgumentException('Allowed private hosts must be strings.'); + } + + $normalized = rtrim(strtolower(trim($pattern)), '.'); + + // `*` is only allowed as a whole leading label (e.g. `*.ddev.site`), so that + // a pattern like `api*` can’t also match `api.attacker.com` + $name = str_starts_with($normalized, '*.') ? substr($normalized, 2) : $normalized; + + if ($name === '' || str_contains($name, '*')) { + throw new \InvalidArgumentException("“{$pattern}” isn’t a valid allowed private host."); + } + + return $normalized; } /** From 623576066eb22ba104187f2c681d332ed91b293d Mon Sep 17 00:00:00 2001 From: i-just Date: Fri, 9 Oct 2026 10:25:36 +0200 Subject: [PATCH 3/6] bugfix for ipv6 --- CHANGELOG.md | 1 + README.md | 15 +++++++++------ src/UrlValidator.php | 21 ++++++++++++++++++++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 096270b..ec4c229 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to `url-validator` will be documented in this file. - Added `UrlValidator::curlResolve()`. - Added `GuzzleMiddleware`, which validates and pins every request a Guzzle client sends, including redirects. - Added the `allowedPrivateHosts` option, which lets specific hostnames resolve to private IP addresses. +- Added the `disallowedIpv6Addresses` option. By default, it blocks the AWS and GCP IPv6 metadata addresses. ## 1.1.0 - 2026-07-06 diff --git a/README.md b/README.md index 8a7fc01..dee58d4 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ The following options can also be configured by passing an array to the construc - `disallowedHostnames` - `disallowedIpv4Addresses` - `disallowedIpv4Ranges` +- `disallowedIpv6Addresses` - `ipv4FilterFlags` (the `FILTER_FLAG_IPV4` will always be added automatically) - `ipv6FilterFlags` (the `FILTER_FLAG_IPV6` will always be added automatically) - `allowedPrivateHosts` (see [Allowing internal hosts](#allowing-internal-hosts)) @@ -135,6 +136,14 @@ curl_setopt($ch, CURLOPT_RESOLVE, $validator->curlResolve($url)); Requires `guzzlehttp/guzzle`. `GuzzleMiddleware` validates and pins every request a Guzzle client sends, including the ones sent while following redirects. A disallowed URL makes the request throw an `UrlValidationException`. +If the client has already been created, you can add the middleware to its handler stack: + +```php +GuzzleMiddleware::attach($client, $validator); +``` + +Otherwise, create the client with the handler stack: + ```php use CraftCms\UrlValidator\GuzzleMiddleware; use GuzzleHttp\Client; @@ -145,12 +154,6 @@ GuzzleMiddleware::push($stack, $validator); $client = new Client(['handler' => $stack]); ``` -If the client has already been created, you can add the middleware to its handler stack instead: - -```php -GuzzleMiddleware::attach($client, $validator); -``` - > [!NOTE] > - Pinning only works with Guzzle’s curl handler. The middleware turns the `stream` request option off, because the stream handler would ignore the pin. > - When requests go through an HTTP proxy, the proxy resolves the hostname itself, so the pin doesn’t apply there. URLs are still validated, but the proxy should enforce its own egress rules. diff --git a/src/UrlValidator.php b/src/UrlValidator.php index 110ed80..b07bba6 100644 --- a/src/UrlValidator.php +++ b/src/UrlValidator.php @@ -51,6 +51,17 @@ class UrlValidator '192.0.0.192', // Oracle ]; + /** + * @var string[] A list of disallowed IPv6 addresses. + * + * By default, these are the cloud metadata IPv6 addresses. They’re in the unique local range, + * so they need to stay blocked even for hosts that are allowed to resolve to private IPs. + */ + private array $disallowedIpv6Addresses = [ + 'fd00:ec2::254', // AWS + 'fd20:ce::254', // GCP + ]; + /** * @var array{0:string,1:int}[] A list of disallowed IPv4 subnets. * @@ -90,7 +101,7 @@ class UrlValidator * @param callable(string):string[]|null $resolver A custom hostname resolver, primarily * useful for testing. Receives a hostname and should return its IP addresses. Defaults to * resolving against the system DNS via [[resolveHostIps()]]. - * @param array{allowedSchemes?:string[],disallowedHostnames?:string[],disallowedIpv4Addresses?:string[],disallowedIpv4Ranges?:array{0:string,1:int}[],ipv4FilterFlags?:int,ipv6FilterFlags?:int,allowedPrivateHosts?:string[]}|null $options Overrides for the default validation rules. Any key that’s omitted falls back to its default. `ipv4FilterFlags`/`ipv6FilterFlags` are combined with `FILTER_FLAG_IPV4`/`FILTER_FLAG_IPV6` respectively, so those don’t need to be included. + * @param array{allowedSchemes?:string[],disallowedHostnames?:string[],disallowedIpv4Addresses?:string[],disallowedIpv4Ranges?:array{0:string,1:int}[],disallowedIpv6Addresses?:string[],ipv4FilterFlags?:int,ipv6FilterFlags?:int,allowedPrivateHosts?:string[]}|null $options Overrides for the default validation rules. Any key that’s omitted falls back to its default. `ipv4FilterFlags`/`ipv6FilterFlags` are combined with `FILTER_FLAG_IPV4`/`FILTER_FLAG_IPV6` respectively, so those don’t need to be included. * * @throws \InvalidArgumentException if an `allowedPrivateHosts` entry isn’t a valid pattern. */ @@ -103,6 +114,7 @@ public function __construct(?callable $resolver = null, ?array $options = null) $this->disallowedHostnames = $options['disallowedHostnames'] ?? $this->disallowedHostnames; $this->disallowedIpv4Addresses = $options['disallowedIpv4Addresses'] ?? $this->disallowedIpv4Addresses; $this->disallowedIpv4Ranges = $options['disallowedIpv4Ranges'] ?? $this->disallowedIpv4Ranges; + $this->disallowedIpv6Addresses = $options['disallowedIpv6Addresses'] ?? $this->disallowedIpv6Addresses; if (isset($options['ipv4FilterFlags'])) { // if the options come from the user, ensure the ipv4 flag is always set @@ -299,6 +311,13 @@ private function validateIpv6(string $ip, string $packed, bool $allowPrivate): b } } + // compare the packed forms, so that equivalent spellings of an address can’t slip through + foreach ($this->disallowedIpv6Addresses as $disallowedIp) { + if (@inet_pton($disallowedIp) === $packed) { + return false; + } + } + // Site-local fec0::/10 (deprecated, RFC 3879) — PHP doesn’t flag it reserved if (ord($packed[0]) === 0xFE && (ord($packed[1]) & 0xC0) === 0xC0) { return false; From 69ed5590764f2b34df5173bfe3f46b2de6161c43 Mon Sep 17 00:00:00 2001 From: i-just Date: Fri, 9 Oct 2026 10:25:47 +0200 Subject: [PATCH 4/6] tests --- tests/AllowedPrivateHostsTest.php | 116 +++++++++++++++++++++ tests/CurlResolveTest.php | 46 ++++++++ tests/GuzzleMiddlewareTest.php | 167 ++++++++++++++++++++++++++++++ tests/OptionsTest.php | 57 ++++++++++ 4 files changed, 386 insertions(+) create mode 100644 tests/AllowedPrivateHostsTest.php create mode 100644 tests/CurlResolveTest.php create mode 100644 tests/GuzzleMiddlewareTest.php create mode 100644 tests/OptionsTest.php diff --git a/tests/AllowedPrivateHostsTest.php b/tests/AllowedPrivateHostsTest.php new file mode 100644 index 0000000..6871e79 --- /dev/null +++ b/tests/AllowedPrivateHostsTest.php @@ -0,0 +1,116 @@ + $resolvedIps, ['allowedPrivateHosts' => $allowedPrivateHosts]); +} + +// If you put a hostname on the allowlist, that host is allowed to point at an internal network address (like 10.0.0.5 or 192.168.1.10), +// which would normally be blocked +it('lets allowlisted hosts resolve to private IPs', function (array $patterns, string $url, array $resolvedIps) { + expect(allowlistValidator($patterns, $resolvedIps)->validate($url))->toBe($resolvedIps); +})->with([ + 'exact host, 10/8' => [['api.internal'], 'http://api.internal/', ['10.0.0.5']], + 'exact host, 172.16/12' => [['api.internal'], 'http://api.internal/', ['172.16.5.4']], + 'exact host, 192.168/16' => [['api.internal'], 'http://api.internal/', ['192.168.1.10']], + 'exact host, ula v6' => [['api.internal'], 'http://api.internal/', ['fd12::1']], + 'wildcard, one label' => [['*.svc.cluster.local'], 'http://x.svc.cluster.local/', ['10.0.0.5']], + 'wildcard, two labels' => [['*.svc.cluster.local'], 'http://a.b.svc.cluster.local/', ['10.0.0.5']], + 'host case and trailing dot' => [['api.internal'], 'http://API.Internal./', ['10.0.0.5']], + 'pattern case and trailing dot' => [['API.internal.'], 'http://api.internal/', ['10.0.0.5']], + 'public and private ips' => [['api.internal'], 'http://api.internal/', ['8.8.8.8', '10.0.0.5']], + 'public ip' => [['api.internal'], 'http://api.internal/', ['8.8.8.8']], +]); + +// allowlisting never unblocks loopback, link-local, metadata (including IPv6 metadata), CGNAT, other reserved ranges, or IPv4 hidden inside IPv6 +it('still rejects disallowed IPs for allowlisted hosts', function (string $ip) { + expect(fn () => allowlistValidator(['api.internal'], [$ip])->validate('http://api.internal/')) + ->toThrow(UrlValidationException::class, 'resolves to an invalid IP address'); +})->with([ + 'loopback' => ['127.0.0.1'], + 'ipv6 loopback' => ['::1'], + 'link-local v4' => ['169.254.1.1'], + 'ipv6 link-local' => ['fe80::1'], + 'aws metadata ip' => ['169.254.169.254'], + 'alibaba metadata ip' => ['100.100.100.200'], + 'oracle metadata ip' => ['192.0.0.192'], + 'aws metadata v6' => ['fd00:ec2::254'], + 'aws metadata v6, other spelling' => ['FD00:0EC2:0:0::254'], + 'gcp metadata v6' => ['fd20:ce::254'], + 'cgnat' => ['100.64.0.1'], + 'protocol assignments' => ['192.0.0.171'], + 'benchmarking range' => ['198.18.0.1'], + 'ipv6 unspecified' => ['::'], + 'ipv4-mapped private' => ['::ffff:10.0.0.1'], + 'nat64 private' => ['64:ff9b::a00:1'], + '6to4 private' => ['2002:a00:1::'], + 'site-local v6' => ['fec0::1'], +]); + +// One bad IP, such as loopback, among otherwise acceptable private IPs fails the whole URL. +it('rejects allowlisted hosts if any of their IPs is disallowed', function () { + expect(fn () => allowlistValidator(['api.internal'], ['10.0.0.5', '127.0.0.1'])->validate('http://api.internal/')) + ->toThrow(UrlValidationException::class, 'resolves to an invalid IP address'); +}); + +it('only relaxes the check for hosts that match', function (array $patterns, string $url) { + expect(fn () => allowlistValidator($patterns, ['10.0.0.5'])->validate($url)) + ->toThrow(UrlValidationException::class, 'resolves to an invalid IP address'); +})->with([ + 'host not on the list' => [['api.internal'], 'http://other.internal/'], + 'empty list' => [[], 'http://api.internal/'], + 'wildcard apex' => [['*.svc.cluster.local'], 'http://svc.cluster.local/'], + 'wildcard without dot boundary' => [['*.svc.cluster.local'], 'http://evilsvc.cluster.local/'], + 'exact host without dot boundary' => [['api.internal'], 'http://xapi.internal/'], + 'exact host as a prefix' => [['api.internal'], 'http://api.internal.attacker.com/'], + 'exact host as a subdomain' => [['api.internal'], 'http://sub.api.internal/'], +]); + +// Some hostnames are always blocked because they're known routes to sensitive internal. +// Listing kubernetes.default.svc, metadata.google.internal or an IP literal doesn't let it through. +it('keeps blocking disallowed hostnames even if allowlisted', function (array $patterns, string $url) { + expect(fn () => allowlistValidator($patterns, ['10.0.0.5'])->validate($url)) + ->toThrow(UrlValidationException::class, 'contains an invalid hostname'); +})->with([ + 'k8s domain' => [['kubernetes.default.svc'], 'http://kubernetes.default.svc/'], + 'gce metadata domain' => [['metadata.google.internal'], 'http://metadata.google.internal/'], + 'ip literal' => [['10.0.0.1'], 'http://10.0.0.1/'], +]); + +it('rejects invalid patterns', function (mixed $pattern) { + expect(fn () => new UrlValidator(options: ['allowedPrivateHosts' => [$pattern]])) + ->toThrow(InvalidArgumentException::class); +})->with([ + 'bare wildcard' => ['*'], + 'wildcard without a domain' => ['*.'], + 'trailing wildcard' => ['api*'], + 'wildcard in the middle' => ['a.*.b'], + 'partial leading wildcard' => ['*foo.com'], + 'double wildcard' => ['**.foo'], + 'empty string' => [''], + 'integer' => [123], + 'null' => [null], +]); + +it('lets validateIp() allow private IPs', function () { + $validator = new UrlValidator; + + expect($validator->validateIp('10.0.0.1'))->toBeFalse() + ->and($validator->validateIp('10.0.0.1', true))->toBeTrue() + ->and($validator->validateIp('fd12::1', true))->toBeTrue() + ->and($validator->validateIp('127.0.0.1', true))->toBeFalse() + ->and($validator->validateIp('169.254.169.254', true))->toBeFalse() + ->and($validator->validateIp('fd00:ec2::254', true))->toBeFalse(); +}); diff --git a/tests/CurlResolveTest.php b/tests/CurlResolveTest.php new file mode 100644 index 0000000..c4d4b50 --- /dev/null +++ b/tests/CurlResolveTest.php @@ -0,0 +1,46 @@ + $resolvedIps); + + expect($validator->curlResolve($url))->toBe([$expected]); +})->with([ + 'http default port' => ['http://example.com/a', ['93.184.216.34'], 'example.com:80:93.184.216.34'], + 'https default port' => ['https://example.com/a', ['93.184.216.34'], 'example.com:443:93.184.216.34'], + 'uppercase scheme' => ['HTTPS://example.com/a', ['93.184.216.34'], 'example.com:443:93.184.216.34'], + 'explicit port' => ['https://example.com:8443/a', ['93.184.216.34'], 'example.com:8443:93.184.216.34'], + 'multiple ipv4' => ['https://example.com/a', ['93.184.216.34', '8.8.8.8'], 'example.com:443:93.184.216.34,8.8.8.8'], + 'ipv6 is bracketed' => ['https://example.com/a', ['2606:4700::1'], 'example.com:443:[2606:4700::1]'], + 'ipv4 and ipv6' => ['https://example.com/a', ['1.2.3.4', '2606:4700::1'], 'example.com:443:1.2.3.4,[2606:4700::1]'], + 'host as given' => ['https://Example.COM/a', ['93.184.216.34'], 'Example.COM:443:93.184.216.34'], +]); + +it('throws when the URL is disallowed', function (string $url, array $resolvedIps, string $exception) { + $validator = new UrlValidator(fn (): array => $resolvedIps); + + expect(fn () => $validator->curlResolve($url)) + ->toThrow(UrlValidationException::class, $exception); +})->with([ + 'bad scheme' => ['ftp://example.com/a', ['93.184.216.34'], 'contains an invalid scheme'], + 'ip literal' => ['http://10.0.0.1/a', [], 'contains an invalid hostname'], + 'private ip' => ['http://example.com/a', ['10.0.0.1'], 'resolves to an invalid IP address'], +]); + +it('resolves the host only once', function () { + $calls = 0; + $validator = new UrlValidator(function () use (&$calls): array { + $calls++; + + return ['93.184.216.34']; + }); + + $validator->curlResolve('https://example.com/a'); + + expect($calls)->toBe(1); +}); diff --git a/tests/GuzzleMiddlewareTest.php b/tests/GuzzleMiddlewareTest.php new file mode 100644 index 0000000..00e523b --- /dev/null +++ b/tests/GuzzleMiddlewareTest.php @@ -0,0 +1,167 @@ +}> $log + */ +function recordingClient(array $responses, UrlValidator $validator, array &$log): Client +{ + $mock = new MockHandler($responses); + $stack = HandlerStack::create(function (RequestInterface $request, array $options) use ($mock, &$log) { + $log[] = ['url' => (string) $request->getUri(), 'options' => $options]; + + return $mock($request, $options); + }); + GuzzleMiddleware::push($stack, $validator); + + return new Client(['handler' => $stack]); +} + +/** + * Builds a validator that resolves hosts from the given map, and counts the lookups. + * + * @param array $dns + * @param string[] $allowedPrivateHosts + */ +function mappedValidator(array $dns, array $allowedPrivateHosts = [], ?int &$lookups = null): UrlValidator +{ + $lookups = 0; + + return new UrlValidator(function (string $host) use ($dns, &$lookups): array { + $lookups++; + + return $dns[$host] ?? []; + }, ['allowedPrivateHosts' => $allowedPrivateHosts]); +} + +/** + * Returns the CURLOPT_RESOLVE value each recorded request was sent with. + * + * @param array}> $log + * @return array + */ +function pins(array $log): array +{ + return array_map(fn (array $entry) => $entry['options']['curl'][CURLOPT_RESOLVE] ?? null, $log); +} + +$dns = [ + 'example.com' => ['93.184.216.34'], + 'other.example' => ['8.8.8.8'], + 'evil.example' => ['10.0.0.5'], + 'api.internal' => ['10.0.0.6'], +]; + +it('pins the first request', function () use ($dns) { + $log = []; + $response = recordingClient([new Response(200)], mappedValidator($dns), $log)->get('https://example.com/a'); + + expect($response->getStatusCode())->toBe(200) + ->and(pins($log))->toBe([['example.com:443:93.184.216.34']]); +}); + +it('validates and pins every redirect', function () use ($dns) { + $log = []; + $client = recordingClient([ + new Response(302, ['Location' => '/b']), + new Response(301, ['Location' => 'http://other.example:8080/c']), + new Response(200), + ], mappedValidator($dns), $log); + + expect($client->get('https://example.com/a')->getStatusCode())->toBe(200) + ->and(array_column($log, 'url'))->toBe(['https://example.com/a', 'https://example.com/b', 'http://other.example:8080/c']) + ->and(pins($log))->toBe([ + ['example.com:443:93.184.216.34'], + ['example.com:443:93.184.216.34'], + ['other.example:8080:8.8.8.8'], + ]); +}); + +it('blocks disallowed redirect targets before they are requested', function (string $location, string $exception) use ($dns) { + $log = []; + $client = recordingClient([ + new Response(302, ['Location' => $location]), + new Response(200), + ], mappedValidator($dns), $log); + + expect(fn () => $client->get('https://example.com/a')) + ->toThrow(UrlValidationException::class, $exception) + ->and(array_column($log, 'url'))->toBe(['https://example.com/a']); +})->with([ + 'host resolving to a private ip' => ['http://evil.example/', 'resolves to an invalid IP address'], + 'ip literal' => ['http://169.254.169.254/latest/meta-data/', 'contains an invalid hostname'], + 'metadata domain' => ['http://metadata.google.internal/', 'contains an invalid hostname'], +]); + +it('follows redirects to allowlisted internal hosts', function () use ($dns) { + $log = []; + $client = recordingClient([ + new Response(302, ['Location' => 'http://api.internal/x']), + new Response(200), + ], mappedValidator($dns, ['api.internal']), $log); + + expect($client->get('https://example.com/a')->getStatusCode())->toBe(200) + ->and(pins($log)[1])->toBe(['api.internal:80:10.0.0.6']); +}); + +it('turns the stream option off', function () use ($dns) { + $log = []; + recordingClient([new Response(200)], mappedValidator($dns), $log)->get('https://example.com/a', ['stream' => true]); + + expect($log[0]['options']['stream'])->toBeFalse(); +}); + +it('respects allow_redirects being off', function () use ($dns) { + $log = []; + $client = recordingClient([ + new Response(302, ['Location' => 'http://evil.example/']), + new Response(200), + ], mappedValidator($dns), $log); + + expect($client->get('https://example.com/a', ['allow_redirects' => false])->getStatusCode())->toBe(302) + ->and($log)->toHaveCount(1); +}); + +it('only adds the middleware once', function () use ($dns) { + $log = []; + $validator = mappedValidator($dns, [], $lookups); + $mock = new MockHandler([new Response(302, ['Location' => '/b']), new Response(200)]); + $stack = HandlerStack::create($mock); + GuzzleMiddleware::push($stack, $validator); + GuzzleMiddleware::push($stack, $validator); + + (new Client(['handler' => $stack]))->get('https://example.com/a'); + + // one lookup per hop + expect($lookups)->toBe(2); +}); + +it('attaches to clients with a handler stack', function () use ($dns) { + $validator = mappedValidator($dns); + + expect(GuzzleMiddleware::attach(new Client(['handler' => HandlerStack::create(new MockHandler)]), $validator))->toBeTrue() + ->and(GuzzleMiddleware::attach(new Client(['handler' => fn () => null]), $validator))->toBeFalse(); +}); + +it('uses a default validator', function () { + $stack = HandlerStack::create(new MockHandler([new Response(200)])); + $stack->push(GuzzleMiddleware::validateRequests()); + + // an IP literal is rejected before any DNS lookup happens + expect(fn () => (new Client(['handler' => $stack]))->get('http://127.0.0.1/')) + ->toThrow(UrlValidationException::class, 'contains an invalid hostname'); +}); diff --git a/tests/OptionsTest.php b/tests/OptionsTest.php new file mode 100644 index 0000000..005e404 --- /dev/null +++ b/tests/OptionsTest.php @@ -0,0 +1,57 @@ + ['93.184.216.34'], ['allowedSchemes' => ['http', 'https', 'ftp']]); + + expect($validator->validate('ftp://example.com/a'))->toBe(['93.184.216.34']); +}); + +it('can disallow other hostnames', function () { + $validator = new UrlValidator(fn (): array => ['93.184.216.34'], ['disallowedHostnames' => ['blocked.example.com']]); + + expect(fn () => $validator->validate('https://blocked.example.com/a')) + ->toThrow(UrlValidationException::class, 'contains an invalid hostname'); +}); + +it('can disallow other IPv4 addresses', function () { + $validator = new UrlValidator(fn (): array => ['93.184.216.34'], ['disallowedIpv4Addresses' => ['93.184.216.34']]); + + expect(fn () => $validator->validate('https://example.com/a')) + ->toThrow(UrlValidationException::class, 'resolves to an invalid IP address'); +}); + +it('can disallow other IPv4 ranges', function () { + $validator = new UrlValidator(fn (): array => ['93.184.216.34'], ['disallowedIpv4Ranges' => [['93.184.0.0', 16]]]); + + expect(fn () => $validator->validate('https://example.com/a')) + ->toThrow(UrlValidationException::class, 'resolves to an invalid IP address'); +}); + +it('can disallow other IPv6 addresses', function () { + $validator = new UrlValidator(fn (): array => ['2606:4700::1'], ['disallowedIpv6Addresses' => ['2606:4700:0::1']]); + + expect(fn () => $validator->validate('https://example.com/a')) + ->toThrow(UrlValidationException::class, 'resolves to an invalid IP address'); +}); + +it('can change the IPv4 filter flags', function () { + $validator = new UrlValidator(options: ['ipv4FilterFlags' => FILTER_FLAG_NO_RES_RANGE]); + + expect($validator->validateIp('10.0.0.1'))->toBeTrue() + ->and($validator->validateIp('127.0.0.1'))->toBeFalse(); +}); + +it('always adds the IP version flag to custom filter flags', function () { + $validator = new UrlValidator(options: ['ipv4FilterFlags' => FILTER_FLAG_NO_RES_RANGE, 'ipv6FilterFlags' => FILTER_FLAG_NO_RES_RANGE]); + + // an IPv6 address mustn’t pass the IPv4 check, and vice versa + $method = new ReflectionMethod(UrlValidator::class, 'validateIpv4'); + + expect($method->invoke($validator, '2606:4700::1', false))->toBeFalse() + ->and($validator->validateIp('fd12::1'))->toBeTrue(); +}); From cd38f44d67925fb83c316f7b1b4b8898ae3cc979 Mon Sep 17 00:00:00 2001 From: i-just Date: Fri, 9 Oct 2026 10:56:21 +0200 Subject: [PATCH 5/6] updated readme --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index dee58d4..e256436 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,8 @@ $validator = new UrlValidator(options: [ - Only the private-range check is relaxed for these hosts. Loopback, link-local, reserved and cloud-metadata addresses, and disallowed hostnames such as `kubernetes.default.svc`, are still rejected. > [!WARNING] -> - Only list hostnames whose DNS you control. Never list wildcard-DNS services such as `nip.io` or `sslip.io`, which resolve to whatever IP address is in the name. +> - Only list hostnames whose DNS you control. > - List individual services rather than whole internal domains. Every listed host is reachable by anyone who can make your app send a request to it. -> - Don’t let users edit this list (e.g. through a control panel). Keep it in code or config files. ### Pinning with curl From 3b3614d60cb98690a8ac966997816d03a0ae38fb Mon Sep 17 00:00:00 2001 From: i-just Date: Fri, 9 Oct 2026 12:00:31 +0200 Subject: [PATCH 6/6] updated readme --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4c229..37c0eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ All notable changes to `url-validator` will be documented in this file. -## 1.2.0 - unreleased +## Unreleased - Added `UrlValidator::curlResolve()`. - Added `GuzzleMiddleware`, which validates and pins every request a Guzzle client sends, including redirects.