Skip to content

fix(nginx): stop the sidecar forwarding a client-supplied X-Forwarded-For - #135

Open
omarsy wants to merge 1 commit into
mainfrom
fix/nginx-xff-no-client-spoof
Open

fix(nginx): stop the sidecar forwarding a client-supplied X-Forwarded-For#135
omarsy wants to merge 1 commit into
mainfrom
fix/nginx-xff-no-client-spoof

Conversation

@omarsy

@omarsy omarsy commented Aug 20, 2026

Copy link
Copy Markdown
Member

The per-instance nginx sidecar set X-Forwarded-For from $proxy_add_x_forwarded_for, which appends the peer to whatever the client sent. Consumers read the leftmost entry, so any unauthenticated client could choose the address an app records against a request.

Not theoretical

Against the Keycloak greffon now on catalog main, one request:

curl -H 'X-Forwarded-For: 203.0.113.77' ...   →  admin event log: ipAddress=203.0.113.77

On an identity provider that log is a security control. A forgeable address is worse than an unhelpful one, because it can frame a third party and poison anything built on it, such as a fail2ban rule. The Keycloak entry currently ships a comment telling operators to treat that field as a hint rather than evidence, and points here for the fix.

Why $remote_addr is the right source

This sidecar is the trust boundary: it terminates TLS and is the first hop we control, so there is no upstream value worth preserving. X-Real-IP already used $remote_addr; this makes the two agree.

What each mode yields, checked rather than assumed:

mode X-Forwarded-For becomes
proxy the real client address (Linux docker publishing preserves the source through DNAT)
tunnel the tunnel sidecar's address

Tunnel mode deserves the explicit note: rathole is a plain TCP tunnel, adding no HTTP headers and no PROXY protocol, so the client's address is already lost at the transport before nginx sees it. Constant and unhelpful, but true. Recovering per-client attribution there needs PROXY protocol on both rathole ends plus real_ip_header proxy_protocol here. That is a larger change, and it is not worth faking with the append form in the meantime.

Verification

Ran the real rendered config against a header-echoing upstream. Client sent X-Forwarded-For: 203.0.113.77, 198.51.100.9; the app received:

X-Real-IP: 192.168.65.1
X-Forwarded-For: 192.168.65.1
X-Forwarded-Proto: https

Forged chain gone, X-Forwarded-Proto unaffected. That peer is the Docker Desktop gateway because the probe ran on macOS, where Desktop NATs through its VM; on a Linux greffer the same path yields the client's own address.

Adds a regression test asserting the directive uses $remote_addr and that the append form appears in no directive (it survives only in the comment naming it as the thing not to use). Mutation-checked: restoring $proxy_add_x_forwarded_for fails it.

Scope

test_nginx_conf and test_l4_network_exposure, the two suites that assert on rendered nginx content, pass (35 tests). The full suite was not run to completion: this machine sits at load ~150 and starves it, so CI is the real check.

Apps reading X-Real-IP see no change. Apps reading X-Forwarded-For now get one unforgeable entry instead of a client-controlled chain; anything parsing it as a list still works, since a single address is a valid list.

…d X-Forwarded-For

The sidecar set X-Forwarded-For from $proxy_add_x_forwarded_for, which APPENDS
the peer to whatever the client sent. Consumers read the leftmost entry, so any
unauthenticated client could choose the address an app records against a request.

This is not theoretical. Against the Keycloak greffon now on catalog main, a
single `curl -H 'X-Forwarded-For: 203.0.113.77'` on a failed login wrote
ipAddress=203.0.113.77 into its admin event log. On an identity provider that log
is a security control, and a forgeable address is worse than an unhelpful one: it
can frame a third party and poison anything built on it, such as a fail2ban rule.
The Keycloak entry currently ships a comment telling operators to treat that field
as a hint rather than evidence, and points here for the fix.

This sidecar is the trust boundary. It terminates TLS and is the first hop we
control, so there is no upstream value worth preserving, and $remote_addr is the
correct source. X-Real-IP already used it; this makes the two agree.

What each mode yields, checked rather than assumed:

  proxy mode  - the real client address. The browser connects to the sidecar's
                published port, and Linux docker publishing preserves the source
                address through DNAT.
  tunnel mode - the tunnel sidecar's address. rathole is a plain TCP tunnel: it
                adds no HTTP headers and no PROXY protocol, so the client's
                address is already lost at the transport before nginx sees it.
                Constant and unhelpful, but true. Recovering per-client
                attribution there needs PROXY protocol on both rathole ends plus
                real_ip_header proxy_protocol here, which is a bigger change than
                this one and is NOT worth faking with the append form.

Verified end to end by running the real rendered config against a
header-echoing upstream: a request carrying
`X-Forwarded-For: 203.0.113.77, 198.51.100.9` reached the app as
`X-Forwarded-For: 192.168.65.1`, the actual peer, with the forged chain gone and
X-Forwarded-Proto: https unaffected. That peer is the Docker Desktop gateway
because the probe ran on macOS, where Desktop NATs through its VM; on a Linux
greffer the same path yields the client's own address.

Adds a regression test that asserts the directive uses $remote_addr and that the
append form appears in no directive (only in the comment naming it as the thing
not to use). Mutation-checked: restoring $proxy_add_x_forwarded_for fails it.

Suites touching rendered nginx content pass (test_nginx_conf, l4_network_exposure:
35 tests). The full suite was not run to completion: this machine sits at load
~150 and starves it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 583a488971

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

# needed through the tunnel it takes PROXY protocol on both
# rathole ends plus `real_ip_header proxy_protocol` here;
# do NOT reintroduce the append form to fake it.
proxy_set_header X-Forwarded-For $remote_addr;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve client IPs from the trusted public proxy

In deployments using the supported manager/public-proxy path, nginx's peer is the edge proxy rather than the browser—the manager-supplied ports[].url flow is explicitly documented in apps/utils/docker/compose.py as having a public proxy in front. Unconditionally replacing X-Forwarded-For with $remote_addr therefore makes every request appear to originate from that proxy, breaking per-client audit logs, bans, and rate limits. Prevent spoofing by accepting forwarded addresses only from configured trusted proxy ranges (for example with nginx real-IP settings) while overwriting the header for direct, untrusted peers.

Useful? React with 👍 / 👎.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant