rasuvaeff / pinned-http-client
PSR-18 client for untrusted URLs: resolves the host, checks every address and pins the connection to the checked addresses
Requires
- php: 8.3 - 8.5
- ext-curl: *
- ext-filter: *
- guzzlehttp/guzzle: ^7.9
- psr/http-client: ^1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- ergebnis/composer-normalize: ^2.51
- friendsofphp/php-cs-fixer: ^3.95
- infection/infection: ^0.35
- maglnet/composer-require-checker: ^4.17
- rasuvaeff/property-testing-testo: ^1.0
- rasuvaeff/rector-named-literals: ^1.0
- rector/rector: ^2.4
- roave/backward-compatibility-check: ^8.0
- testo/bridge-infection: ^0.1.6
- testo/testo: ^0.10.25
- vimeo/psalm: ^6.16
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-23 06:45:53 UTC
README
PSR-18 client for URLs you did not choose: it resolves the host itself, checks every address it got and pins the connection to the checked addresses, so a DNS answer that changes between the check and the connection cannot lead to an internal host.
Using an AI coding assistant? llms.txt contains a compact API reference you can share with the model.
Why
A URL checked as https://example.com/ is still dangerous when the connection is made later: DNS may answer a public address to the check and 127.0.0.1 or 169.254.169.254 (the cloud metadata service) to the request. Checking the host name is not an SSRF defence, only checking the connected address is. cURL can be told which addresses a name stands for (CURLOPT_RESOLVE); this client resolves the name once, checks every address and gives cURL exactly those.
Requirements
- PHP 8.3-8.5 with
curlandfilter guzzlehttp/guzzle^7.9 with its cURL handler (the default whenext-curlis loaded),psr/http-client,psr/http-message
Installation
composer require rasuvaeff/pinned-http-client
Usage
use GuzzleHttp\Psr7\Request; use Rasuvaeff\PinnedHttpClient\PinnedClient; use Rasuvaeff\PinnedHttpClient\PinningFailed; $client = new PinnedClient(); // PublicAddresses policy, 8 s total, 4 s connect try { $response = $client->sendRequest(new Request('GET', $untrustedUrl)); } catch (PinningFailed $refused) { // not http(s), no host, does not resolve, resolves to a non-public address, or the transport failed }
It is a plain Psr\Http\Client\ClientInterface, so it plugs into anything that takes one, for example the Fetcher of rasuvaeff/link-preview, a webhook sender or your own fetch code.
Redirects are not followed (allow_redirects is forced to false): a redirect target is a new untrusted URL. Follow redirects in the calling code and send each hop through the client again, so every hop is resolved, checked and pinned (the Fetcher of rasuvaeff/link-preview does exactly that).
Public API
| Class | Description |
|---|---|
PinnedClient |
PSR-18 client: resolve, check every address, pin the connection. send($request, $guzzleOptions) adds Guzzle options such as stream |
AddressPolicy |
allows(string $address): bool; your own rules go here |
PublicAddresses |
The default policy: only public addresses |
PinningFailed |
NetworkExceptionInterface; the request and the cause are attached |
The default policy
PublicAddresses refuses private networks (10/8, 172.16/12, 192.168/16), loopback, link-local (169.254/16, which holds the cloud metadata address), CGNAT (100.64/10), documentation and benchmark ranges, multicast, the reserved and broadcast ranges, and for IPv6 the unspecified, loopback, unique-local (fc00::/7), link-local (fe80::/10), site-local, multicast, Teredo, 6to4 and documentation ranges. An IPv6 address that carries an IPv4 one (::ffff:a.b.c.d, NAT64 64:ff9b::/96) is judged as that IPv4. Anything that is not a canonical IP text is refused.
A stricter or looser rule is an AddressPolicy of your own, for instance one that lets a development host through:
use Rasuvaeff\PinnedHttpClient\AddressPolicy; final readonly class PublicOrLoopback implements AddressPolicy { public function __construct(private AddressPolicy $inner) {} public function allows(string $address): bool { return $address === '127.0.0.1' || $this->inner->allows($address); } } $client = new PinnedClient(policy: new PublicOrLoopback(new PublicAddresses()));
Resolver, timeouts, Guzzle client
$client = new PinnedClient( policy: new PublicAddresses(), timeoutSeconds: 5.0, connectTimeoutSeconds: 2.0, guzzle: new \GuzzleHttp\Client(), // your own client, with the cURL handler resolver: static fn (string $host): array => $myDns->ipv4($host), // list of address texts );
The default resolver is gethostbynamel(): IPv4 only, as the system resolver answers. A literal IP in the URL needs no pinning; it only passes the policy.
Security
What it does:
- Resolves the name once, checks every address (one non-public address among public ones refuses the host) and passes exactly those addresses to cURL. TLS still verifies the certificate against the host name.
- Allows only
httpandhttps; refuses a URL without a host. - Forces
allow_redirects: falseand the pinned addresses: options given tosend()cannot override them (other options, such asstreamortimeout, are passed through). - Ships a policy that treats IPv4-in-IPv6 by the embedded IPv4 and refuses odd spellings (
127.1,2130706433,0x7f.0.0.1resolve through the system resolver to127.0.0.1and are refused).
What it cannot do:
- Pinning needs the cURL handler. With a Guzzle client built on the stream handler the address list is ignored. Do not pass such a client.
- A configured HTTP proxy (
HTTP_PROXYand friends, or the client'sproxyoption) means the proxy connects, not cURL; then the proxy is your SSRF boundary. Use an egress proxy that refuses internal destinations, or none. - The name is resolved by the system resolver before the policy runs; a hostile resolver is out of scope.
- It does not limit the size of the response: pass
streamand read a bounded amount, or set a limit in the code that reads the body.
Examples
See examples/ for runnable scripts. Examples are expected to execute without fatal errors and stay aligned with the documented public API.
| Script | Shows | Needs server? |
|---|---|---|
refusals.php |
What the policy allows and refuses, and the client's refusals against a fixed table standing in for DNS | No |
Development
No PHP/Composer on the host — run in Docker via the composer:2 image:
docker run --rm -v "$PWD":/app -w /app composer:2 composer install docker run --rm -v "$PWD":/app -w /app composer:2 composer build docker run --rm -v "$PWD":/app -w /app composer:2 composer cs:fix docker run --rm -v "$PWD":/app -w /app composer:2 composer test docker run --rm -v "$PWD":/app -w /app composer:2 composer release-check
Or with Make:
make install
make build
make cs-fix
make test
make test-coverage
make mutation
make release-check
make test-coverage and make mutation bootstrap pcov inside the
composer:2 container because the base image has no coverage driver.