Summary
The TRUSTED_PROXY environment variable, introduced in #643 and shipped in v4.6.0-beta1 and beta2, does not resolve the real client IP correctly in several common Docker setups. Each web server variation fails differently for the same request, and the failure is silent: no warnings, just the wrong value in $_SERVER['REMOTE_ADDR'].
Test results before the fix
Requests sent from a sibling container on the same Docker network (a private, trusted peer).
TRUSTED_PROXY=local, X-Forwarded-For set
| Scenario |
fpm-apache |
fpm-nginx |
frankenphp |
| 1 hop, public client |
correct |
correct |
peer IP |
| 2 Docker hops |
peer IP |
proxy IP |
peer IP |
| Private LAN client |
peer IP |
correct |
peer IP |
| 3 hops, private middle |
peer IP |
proxy IP |
peer IP |
TRUSTED_PROXY=cloudflare (default), Cloudflare → Traefik (default config) → container
| fpm-apache |
fpm-nginx |
frankenphp |
| correct |
correct |
Traefik IP |
Root causes
1. Apache: RemoteIPTrustedProxy does not resolve through private hops.
mod_remoteip refuses to resolve a client IP through a RemoteIPTrustedProxy address that is itself private. Every Docker hop is private, so any chain longer than one hop stops at the peer. Apache provides RemoteIPInternalProxy for exactly this case.
2. nginx: real_ip_recursive missing from local and sucuri.
Only cloudflare.conf had it. Without it nginx takes the last X-Forwarded-For entry unconditionally, which is the Docker proxy in any multi-hop chain.
3. FrankenPHP: REMOTE_ADDR ignores trusted_proxies.
Caddy resolves the client IP correctly (the access log shows the right client_ip), but FrankenPHP builds REMOTE_ADDR from the raw TCP peer. This is still true on FrankenPHP's main branch; their docs expect the framework to parse X-Forwarded-For itself.
4. FrankenPHP: Caddy is not in strict mode.
Caddy's default trusted_proxies behavior takes the leftmost X-Forwarded-For entry, which is client-supplied. Once REMOTE_ADDR is populated from Caddy's client IP (fix 3), any client behind a trusted Docker proxy could forge their address without trusted_proxies_strict.
Fix
Apache
- Use
RemoteIPInternalProxy for private and loopback ranges in every mode. Keep RemoteIPTrustedProxy for CDN public ranges.
- Add
127.0.0.1/8, ::1, and fd00::/8 to cloudflare and sucuri so all three servers trust the same private ranges.
nginx
- Add
real_ip_recursive on; to local.conf and sucuri.conf.
FrankenPHP
- Add
env REMOTE_ADDR {client_ip} to the php_server block. FrankenPHP's env map is placeholder-expanded per request and registered last, so this is a supported override.
- Add
trusted_proxies_strict to every trusted-proxy snippet.
Docs
- Update the trusted proxies guide to match the above.
What was considered and rejected
Falling back to X-Forwarded-For in cloudflare mode on Apache and nginx.
The default mode is cloudflare, so it's tempting to make it also work for non-Cloudflare traffic. Two approaches were tested and both fail:
- Switching nginx to
real_ip_header X-Forwarded-For. Cloudflare does set XFF, but Traefik (and Caddy as a front proxy) strip incoming X-Forwarded-* headers from untrusted sources by default and rewrite XFF to just the peer, which is the Cloudflare edge IP. CF-Connecting-IP passes through untouched. With Traefik in its default config, this change returned the Cloudflare edge IP as the client. It only works if the user also configures Traefik's forwardedHeaders.trustedIPs, which most people don't.
- Staging XFF into
CF-Connecting-IP on Apache with RequestHeader setifempty ... early. This fabricates a Cloudflare header that PHP then sees in HTTP_CF_CONNECTING_IP. From an untrusted peer, anyone sending X-Forwarded-For got their value promoted into CF-Connecting-IP. With no headers, PHP saw the header present but empty, which breaks isset() checks. It also depends on mod_headers running before mod_remoteip, which only holds because headers.load sorts alphabetically before remoteip.load.
Neither mod_remoteip nor ngx_http_realip_module can read more than one header, so there is no clean way to do this. cloudflare mode uses CF-Connecting-IP on all three servers. Users not behind Cloudflare should set TRUSTED_PROXY=local. FrankenPHP keeps its native CF-Connecting-IP then X-Forwarded-For fallback because Caddy supports multiple headers directly.
Known tradeoffs
- Strict mode on FrankenPHP: if every address in the
X-Forwarded-For chain is trusted (a private LAN client behind a private proxy), FrankenPHP reports the proxy's IP, while Apache and nginx report the leftmost entry. This is the price of a non-forgeable REMOTE_ADDR and is documented.
- Origin bypass: a client that reaches Traefik directly, bypassing Cloudflare, can send a forged
CF-Connecting-IP and Traefik will pass it along. This is inherent to Cloudflare's header and applies before and after this fix. Restrict origin ingress to Cloudflare's IP ranges. Now documented.
Results after the fix
- Direct-peer matrix, 17 scenarios across
local, cloudflare, and off: Apache, nginx, and FrankenPHP return identical values in every row except the two documented FrankenPHP differences. Every spoof attempt from an untrusted peer is rejected.
- Cloudflare → Traefik → container, with Traefik in both default and
trustedIPs configuration: all three servers return the real visitor IP.
Reproduction (before the fix)
./scripts/dev.sh --variation fpm-apache --version 8.4 --os bookworm
docker network create tpnet
docker run -d --name app --network tpnet -e TRUSTED_PROXY=local \
-e SSL_MODE=off -v "$PWD/public:/var/www/html/public:ro" \
serversideup/php:8.4-fpm-apache-bookworm
docker run --rm --network tpnet curlimages/curl -s \
-H "X-Forwarded-For: 203.0.113.5, 172.20.0.9" http://app:8080/
With public/index.php containing <?php echo $_SERVER['REMOTE_ADDR'];
Expected: 203.0.113.5
Actual: peer address
Related
Raised by @mbrodala in the #643 review.
Summary
The
TRUSTED_PROXYenvironment variable, introduced in #643 and shipped in v4.6.0-beta1 and beta2, does not resolve the real client IP correctly in several common Docker setups. Each web server variation fails differently for the same request, and the failure is silent: no warnings, just the wrong value in$_SERVER['REMOTE_ADDR'].Test results before the fix
Requests sent from a sibling container on the same Docker network (a private, trusted peer).
TRUSTED_PROXY=local,X-Forwarded-ForsetTRUSTED_PROXY=cloudflare(default), Cloudflare → Traefik (default config) → containerRoot causes
1. Apache:
RemoteIPTrustedProxydoes not resolve through private hops.mod_remoteiprefuses to resolve a client IP through aRemoteIPTrustedProxyaddress that is itself private. Every Docker hop is private, so any chain longer than one hop stops at the peer. Apache providesRemoteIPInternalProxyfor exactly this case.2. nginx:
real_ip_recursivemissing fromlocalandsucuri.Only
cloudflare.confhad it. Without it nginx takes the lastX-Forwarded-Forentry unconditionally, which is the Docker proxy in any multi-hop chain.3. FrankenPHP:
REMOTE_ADDRignorestrusted_proxies.Caddy resolves the client IP correctly (the access log shows the right
client_ip), but FrankenPHP buildsREMOTE_ADDRfrom the raw TCP peer. This is still true on FrankenPHP's main branch; their docs expect the framework to parseX-Forwarded-Foritself.4. FrankenPHP: Caddy is not in strict mode.
Caddy's default
trusted_proxiesbehavior takes the leftmostX-Forwarded-Forentry, which is client-supplied. OnceREMOTE_ADDRis populated from Caddy's client IP (fix 3), any client behind a trusted Docker proxy could forge their address withouttrusted_proxies_strict.Fix
Apache
RemoteIPInternalProxyfor private and loopback ranges in every mode. KeepRemoteIPTrustedProxyfor CDN public ranges.127.0.0.1/8,::1, andfd00::/8tocloudflareandsucuriso all three servers trust the same private ranges.nginx
real_ip_recursive on;tolocal.confandsucuri.conf.FrankenPHP
env REMOTE_ADDR {client_ip}to thephp_serverblock. FrankenPHP'senvmap is placeholder-expanded per request and registered last, so this is a supported override.trusted_proxies_strictto every trusted-proxy snippet.Docs
What was considered and rejected
Falling back to
X-Forwarded-Forincloudflaremode on Apache and nginx.The default mode is
cloudflare, so it's tempting to make it also work for non-Cloudflare traffic. Two approaches were tested and both fail:real_ip_header X-Forwarded-For. Cloudflare does set XFF, but Traefik (and Caddy as a front proxy) strip incomingX-Forwarded-*headers from untrusted sources by default and rewrite XFF to just the peer, which is the Cloudflare edge IP.CF-Connecting-IPpasses through untouched. With Traefik in its default config, this change returned the Cloudflare edge IP as the client. It only works if the user also configures Traefik'sforwardedHeaders.trustedIPs, which most people don't.CF-Connecting-IPon Apache withRequestHeader setifempty ... early. This fabricates a Cloudflare header that PHP then sees inHTTP_CF_CONNECTING_IP. From an untrusted peer, anyone sendingX-Forwarded-Forgot their value promoted intoCF-Connecting-IP. With no headers, PHP saw the header present but empty, which breaksisset()checks. It also depends onmod_headersrunning beforemod_remoteip, which only holds becauseheaders.loadsorts alphabetically beforeremoteip.load.Neither
mod_remoteipnorngx_http_realip_modulecan read more than one header, so there is no clean way to do this.cloudflaremode usesCF-Connecting-IPon all three servers. Users not behind Cloudflare should setTRUSTED_PROXY=local. FrankenPHP keeps its nativeCF-Connecting-IPthenX-Forwarded-Forfallback because Caddy supports multiple headers directly.Known tradeoffs
X-Forwarded-Forchain is trusted (a private LAN client behind a private proxy), FrankenPHP reports the proxy's IP, while Apache and nginx report the leftmost entry. This is the price of a non-forgeableREMOTE_ADDRand is documented.CF-Connecting-IPand Traefik will pass it along. This is inherent to Cloudflare's header and applies before and after this fix. Restrict origin ingress to Cloudflare's IP ranges. Now documented.Results after the fix
local,cloudflare, andoff: Apache, nginx, and FrankenPHP return identical values in every row except the two documented FrankenPHP differences. Every spoof attempt from an untrusted peer is rejected.trustedIPsconfiguration: all three servers return the real visitor IP.Reproduction (before the fix)
With
public/index.phpcontaining<?php echo $_SERVER['REMOTE_ADDR'];Expected:
203.0.113.5Actual: peer address
Related
Raised by @mbrodala in the #643 review.