Trusted Proxy IP Resolution
TrustedProxyClientIpResolver (v0.6.0) resolves the real client IP behind Cloudflare, an AWS load balancer, or any other proxy chain. It only honours forwarded headers when the immediate connection comes from a trusted CIDR, so unauthenticated callers cannot spoof their IP by setting X-Forwarded-For.
Http\ClientIpResolver (since 0.3.0) keeps working — its constructor and resolve(?array $server = null) API are unchanged. Internally it now delegates to TrustedProxyClientIpResolver, which means the same hardening rules apply to existing 0.3.x callers.
For new code, prefer TrustedProxyClientIpResolver directly — it implements ClientIpResolverInterface and slots into RateLimitMiddleware and IpAllowlistMiddleware cleanly.
Minimal example
use BetterRoute\Middleware\Network\TrustedProxyClientIpResolver;
$resolver = new TrustedProxyClientIpResolver(
trustedProxyCidrs: [
'173.245.48.0/20', // Cloudflare
'103.21.244.0/22',
'2400:cb00::/32',
],
forwardedHeaders: ['CF-Connecting-IP', 'X-Forwarded-For']
);
$clientIp = $resolver->resolve();
Pass it to RateLimitMiddleware for proxy-aware rate-limit keys:
use BetterRoute\Middleware\RateLimit\RateLimitMiddleware;
use BetterRoute\Middleware\RateLimit\TransientRateLimiter;
$rateLimit = new RateLimitMiddleware(
limiter: new TransientRateLimiter(),
limit: 60,
windowSeconds: 60,
clientIpResolver: $resolver
);
RateLimitMiddleware's $clientIpResolver argument now accepts either the legacy Http\ClientIpResolver or any Middleware\Network\ClientIpResolverInterface.
Constructor
new TrustedProxyClientIpResolver(
array $trustedProxyCidrs = [],
array $forwardedHeaders = ['CF-Connecting-IP', 'X-Forwarded-For'],
?callable $serverResolver = null
);
trustedProxyCidrs— list of IPv4/IPv6 CIDRs or single IPs. Each entry is validated at construction.forwardedHeaders— header order the resolver consults whenREMOTE_ADDRis trusted. The first header that yields a valid IP wins.serverResolver— defaults to a callable returning$_SERVER. Inject in tests.
Behavior
REMOTE_ADDR | Trusted? | Forwarded headers present? | Result |
|---|---|---|---|
| missing/invalid | — | — | null |
outside trustedProxyCidrs | no | — | REMOTE_ADDR returned (forwarded headers ignored) |
inside trustedProxyCidrs | yes | first valid IP found | first valid IP from the first matching header |
inside trustedProxyCidrs | yes | none parseable | REMOTE_ADDR returned |
X-Forwarded-For is comma-delimited; the first valid IP wins (left-most, which is the original client per spec).
The resolver checks the request object first ($request->get_header($header)) when one is passed in, then falls back to $_SERVER. Both Header-Name and HTTP_HEADER_NAME shapes are accepted.
CIDR matching
BetterRoute\Middleware\Network\CidrMatcher handles the IPv4/IPv6 math. CIDRs without a /prefix are treated as single hosts (/32 for IPv4, /128 for IPv6). The matcher rejects malformed input at construction:
192.0.2.0/24✓2400:cb00::/32✓203.0.113.5(single host) ✓300.0.0.0/24✗ — not a valid IP192.0.2.0/40✗ — prefix out of range
Use CidrMatcher::matches($ip, $cidr) and CidrMatcher::assertValid($cidr) if you need the same logic outside the resolver.
Validation checklist
- a request with
REMOTE_ADDRfrom outside the trusted CIDR returnsREMOTE_ADDR(proxy headers ignored); - a request from a trusted proxy with
CF-Connecting-IP: 203.0.113.5returns203.0.113.5; - a request from a trusted proxy with
X-Forwarded-For: 203.0.113.5, 10.0.0.1returns203.0.113.5; - a malformed CIDR in the constructor throws
InvalidArgumentException.
Common mistakes
- Passing the proxy's actual IP into
forwardedHeadersthinking it is a CIDR — it must be a header name (e.g.CF-Connecting-IP). - Trusting every CIDR ("just to be safe"). Each entry is a delegation of trust — only list the proxy ranges you control or rely on.
- Letting
Http\ClientIpResolverbe constructed withouttrustedProxies. With an empty list, every header is ignored and onlyREMOTE_ADDRis returned. That is safer than the default WP behavior, but you still need to populate the list to actually use forwarded IPs.