Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

All notable changes to `url-validator` will be documented in this file.

## Unreleased

- 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

- Added an `$options` argument to the `UrlValidator` constructor. ([#2](https://github.com/craftcms/url-validator/pull/2))
Expand Down
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,10 @@ 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))

See the codebase for the default values and expected types.

Expand All @@ -98,6 +100,63 @@ 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.
> - 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.

### 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`.

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;
use GuzzleHttp\HandlerStack;

$stack = HandlerStack::create();
GuzzleMiddleware::push($stack, $validator);
$client = new Client(['handler' => $stack]);
```

> [!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
Expand Down
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/"
Expand Down
78 changes: 78 additions & 0 deletions src/GuzzleMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace CraftCms\UrlValidator;

use GuzzleHttp\Client;
use GuzzleHttp\HandlerStack;
use Psr\Http\Message\RequestInterface;

/**
* Guzzle middleware that validates every request’s URL, including the ones sent when following redirects.
*
* Requires `guzzlehttp/guzzle`. A disallowed URL makes the request fail with an [[UrlValidationException]].
*
* @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
*/
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;
}
}
Loading
Loading