chubbyphp / chubbyphp-trusted-proxy
A trusted proxy middleware for PSR 15: resolves the client ip, scheme and host from forwarded headers.
Package info
github.com/chubbyphp/chubbyphp-trusted-proxy
pkg:composer/chubbyphp/chubbyphp-trusted-proxy
Requires
- php: ^8.3
- psr/http-message: ^1.1|^2.0
- psr/http-server-handler: ^1.0.2
- psr/http-server-middleware: ^1.0.2
Requires (Dev)
- chubbyphp/chubbyphp-dev-helper: dev-master
- chubbyphp/chubbyphp-laminas-config-factory: ^1.5.2
- chubbyphp/chubbyphp-mock: ^2.2.1
- infection/infection: ^0.34.0
- nyholm/psr7: ^1.8.2
- php-coveralls/php-coveralls: ^2.9.1
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.2.5
- phpunit/phpunit: ^12.5.30
Suggests
This package is auto-updated.
Last update: 2026-08-30 13:10:49 UTC
README
Description
A trusted proxy middleware for PSR 15: resolves the client ip, scheme and host from the forwarded headers
(X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host) of trusted proxies into request attributes.
Requirements
- php: ^8.3
- psr/http-message: ^1.1|^2.0
- psr/http-server-handler: ^1.0.2
- psr/http-server-middleware: ^1.0.2
Suggest
Installation
Through Composer as chubbyphp/chubbyphp-trusted-proxy.
composer require chubbyphp/chubbyphp-trusted-proxy "^1.0"
Usage
Behind a reverse proxy (nginx, traefik, a load balancer, ...) the server only sees the proxy, the client data arrives
within the forwarded headers, which any client can send as well. The middleware decides which entries of these headers
to trust and passes the request on with the clientIp, scheme and host attributes set, so that every other part
(rate limiting, logging, access control, url generation, ...) reads them from one place instead of parsing headers.
<?php declare(strict_types=1); namespace App; use Chubbyphp\TrustedProxy\ForwardedResolver; use Chubbyphp\TrustedProxy\TrustedProxyAttributes; use Chubbyphp\TrustedProxy\TrustedProxyMiddleware; use Psr\Http\Message\ServerRequestInterface; $app = ...; // the ips / cidrs of the proxies: the entries of X-Forwarded-For get walked from the right, the first one not within // the ranges is the client (robust against a varying number of hops) $app->add(new TrustedProxyMiddleware(new ForwardedResolver(['10.0.0.0/8', '::1']))); $handler = static function (ServerRequestInterface $request) { // each one ?string: the unresolved ones are null $clientIp = $request->getAttribute(TrustedProxyAttributes::CLIENT_IP); // 'clientIp' $scheme = $request->getAttribute(TrustedProxyAttributes::SCHEME); // 'scheme' $host = $request->getAttribute(TrustedProxyAttributes::HOST); // 'host' ... };
Register the middleware before any middleware that reads the attributes. Requests without a resolvable client ip
(no X-Forwarded-For, only trusted entries, or a first untrusted entry which is not a valid ip like unknown or
ip:port) get null attributes. The middleware always sets all three attributes (the unresolved ones as null), so
that nothing set before it survives. A subnet matching every ip (0.0.0.0/0, ::/0) gets rejected, as it would trust
every entry and never resolve anything, an empty list as well, as it would trust no entry and resolve the nearest proxy
as client ip, the entries get trimmed. Ipv4 mapped ipv6 addresses (::ffff:10.0.0.1) match ipv4 subnets.
The clientIp gets canonicalized (::ffff:203.0.113.1 as 203.0.113.1, 2001:db8:0:0::1 as 2001:db8::1), so
that the same client always resolves to the same string, no matter how a hop wrote it (rate limit keys, allowlists,
logs).
The scheme and host get only resolved when a client ip was resolved: the entry at the same position, if the header has
as many entries as the X-Forwarded-For header (proxies appending to all of them), the last (the one the nearest proxy
set) otherwise. The scheme gets lowercased and must be http or https, the host must be a syntactically valid host
(a hostname, an ipv4 or a bracketed ipv6, each with an optional port from 1 to 65535), everything else resolves null.
Security
The trust is anchored at the address of the connection, as remoteAddress attribute (set by the server or a middleware
in front, a port gets stripped) or as REMOTE_ADDR server param (as set by php-fpm, apache, ...), the attribute wins
over the server param: a connection from outside the trusted ranges counts as the client itself, its address is the
clientIp and the headers get ignored. A request without any address of the connection resolves nothing (fail closed),
as the middleware cannot verify that the last hop was a trusted proxy. If the server never provides it (some runtimes
build the request without server params), disable the check explicitly, the server must then not be reachable except
through the proxies:
new ForwardedResolver(['10.0.0.0/8'], requireRemoteAddress: false);
Either way, the proxies must set (or strip) all the forwarded headers, as any header they do not touch is supplied by
the client: a proxy passing the client's X-Forwarded-Proto / X-Forwarded-Host through lets the client choose them,
the middleware cannot tell. The clientIp is always a valid ip and the scheme always http or https, but the
host is only checked for its syntax: before using it for url generation or redirects, check it against the hosts the
application serves (an allowlist), so that a passed through X-Forwarded-Host cannot poison generated urls:
$scheme = $request->getAttribute(TrustedProxyAttributes::SCHEME); $host = $request->getAttribute(TrustedProxyAttributes::HOST); if (!in_array($host, ['example.com', 'www.example.com'], true)) { return $responseFactory->createResponse(400); }
The RFC 7239 Forwarded header (for=...;proto=...;host=...) is not supported, only the de-facto X-Forwarded-*
headers (or single value ones like X-Real-IP, see below): if the proxies send Forwarded, configure them to send the
X-Forwarded-* headers as well.
Headers
The second argument replaces the header names (for is required, the others are optional, null disables them),
useful for a proxy setting a single value header like X-Real-IP:
use Chubbyphp\TrustedProxy\ForwardedHeaders; use Chubbyphp\TrustedProxy\ForwardedResolver; new ForwardedResolver(['10.0.0.0/8'], new ForwardedHeaders(for: 'X-Real-IP', proto: 'X-Forwarded-Proto', host: null));
Service factories (chubbyphp-laminas-config-factory)
The package ships service factories (built on chubbyphp-laminas-config-factory) for a PSR 11 container, configured
through config.chubbyphp.trustedProxy:
<?php declare(strict_types=1); namespace App; use Chubbyphp\Laminas\Config\Config; use Chubbyphp\Laminas\Config\ContainerFactory; use Chubbyphp\TrustedProxy\ForwardedResolverInterface; use Chubbyphp\TrustedProxy\ServiceFactory\ForwardedResolverFactory; use Chubbyphp\TrustedProxy\ServiceFactory\TrustedProxyMiddlewareFactory; use Chubbyphp\TrustedProxy\TrustedProxyMiddleware; $container = (new ContainerFactory())(new Config([ 'chubbyphp' => [ 'trustedProxy' => [ 'trustedProxies' => ['10.0.0.0/8', '::1'], // 'headers' => ['for' => 'X-Forwarded-For', 'proto' => 'X-Forwarded-Proto', 'host' => 'X-Forwarded-Host'], // 'requireRemoteAddress' => true, ], ], 'dependencies' => [ 'factories' => [ ForwardedResolverInterface::class => ForwardedResolverFactory::class, TrustedProxyMiddleware::class => TrustedProxyMiddlewareFactory::class, ], ], ])); $trustedProxyMiddleware = $container->get(TrustedProxyMiddleware::class);
The TrustedProxyMiddlewareFactory uses the service ForwardedResolverInterface::class of the container if
registered, and creates it through the shipped ForwardedResolverFactory otherwise. Register it under its name to
replace it or to share it with other services.
With names
To serve different parts of an application behind different proxies (a public load balancer, an internal one, ...),
the same factories can be registered multiple times with a name: the config is then read from
config.chubbyphp.trustedProxy.<name> and the name gets appended to each service id.
$container = (new ContainerFactory())(new Config([ 'chubbyphp' => [ 'trustedProxy' => [ 'public' => ['trustedProxies' => ['10.0.0.0/8', '::1']], 'internal' => ['trustedProxies' => ['192.168.0.0/16'], 'headers' => ['for' => 'X-Real-IP', 'host' => null]], ], ], 'dependencies' => [ 'factories' => [ ForwardedResolverInterface::class.'public' => [ForwardedResolverFactory::class, 'public'], TrustedProxyMiddleware::class.'public' => [TrustedProxyMiddlewareFactory::class, 'public'], ForwardedResolverInterface::class.'internal' => [ForwardedResolverFactory::class, 'internal'], TrustedProxyMiddleware::class.'internal' => [TrustedProxyMiddlewareFactory::class, 'internal'], ], ], ])); $publicTrustedProxyMiddleware = $container->get(TrustedProxyMiddleware::class.'public'); $internalTrustedProxyMiddleware = $container->get(TrustedProxyMiddleware::class.'internal');
Copyright
2026 Dominik Zogg