Skip to content

TRUSTED_PROXY does not resolve the correct client IP in any variation #703

Description

@jaydrogers

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions