chubbyphp/chubbyphp-trusted-proxy

A trusted proxy middleware for PSR 15: resolves the client ip, scheme and host from forwarded headers.

Maintainers

Package info

github.com/chubbyphp/chubbyphp-trusted-proxy

pkg:composer/chubbyphp/chubbyphp-trusted-proxy

Transparency log

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.0.0 2026-08-30 12:55 UTC

This package is auto-updated.

Last update: 2026-08-30 13:10:49 UTC


README

CI Coverage Status Mutation testing badge Latest Stable Version Total Downloads Monthly Downloads

bugs code_smells coverage duplicated_lines_density ncloc sqale_rating alert_status reliability_rating security_rating sqale_index vulnerabilities

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

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