perf(core): sanitize the X-Forwarded-* headers in the NGINX config - #13803
perf(core): sanitize the X-Forwarded-* headers in the NGINX config#13803AlinsRan wants to merge 6 commits into
Conversation
`handle_x_forwarded_headers` ran on every request to overwrite X-Forwarded-Proto/Host/Port and clear Forwarded, and `set_upstream_x_forwarded_headers` then copied the result into `$var_x_forwarded_*` for `proxy_set_header`. Both do work the configuration can do in C, and both run on the path that matters most: with no `apisix.trusted_addresses` set -- the default -- no peer is trusted, so every request takes the same branch. `more_set_input_headers` now neutralizes `r->headers_in` in the rewrite phase, and two maps derive the observed host and port from the Host header. The upstream-facing `proxy_set_header X-Forwarded-Proto/Host/Port` are removed along with `$var_x_forwarded_*`. `r->headers_in` already holds the values the request should carry and `proxy_pass` forwards it as it stands, so there is nothing left to copy -- and nothing that can overwrite a plugin's rewrite of those headers, which is what the Lua copier existed to preserve. `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for` stays: only that variable appends the connection address. What is left needs a trust decision, so it stays in Lua behind a check that is a constant for the worker's lifetime: with no `trusted_addresses` configured `handle_trusted_x_forwarded_headers` returns on its first line. When a boundary does exist, `set $apisix_orig_xf_*` takes copies before the overwrite, a trusted peer's values are restored from them, and an untrusted peer additionally loses the inbound X-Forwarded-For chain. The copies are taken unconditionally rather than behind a template guard, so that a trust boundary the CLI cannot see at render time still has something to restore from. Behaviour is unchanged. `t/core/trusted-addresses.t` gains five cases covering a Host that carries a port, a request with no Host header at all, a trusted peer that sent no X-Forwarded-* header, a trusted peer whose values a `proxy-rewrite` then rewrites, and an untrusted peer measured against a configured boundary. Each expectation was taken from what the previous implementation produced for the same request. One assertion moved: TEST 1 no longer expects `trusted_addresses_matcher is not initialized` in the error log, because with no boundary configured the new code returns before consulting the matcher. The assertion is kept, inverted, in a `--- no_error_log` block. `t/APISIX.pm` mirrors the config, since Test::Nginx generates its own nginx.conf rather than rendering `ngx_tpl.lua`.
The Perl heredoc emitted `\d+` where `ngx_tpl.lua` emits `\\d+`. Both reach NGINX as the same regex, so the harness was not testing a different pattern, but the two rendered configs differing on a line the harness comments as mirroring the template invites the question every time it is read.
Review turned up a parity gap the differential had missed, because the test that covered the case used the harness default `Host: localhost` -- no port, already lower-case -- the one input where old and new coincide. For a trusted peer that sent no `X-Forwarded-Host` / `X-Forwarded-Port`, the Lua-only implementation skipped the rewrite entirely: the headers stayed absent and the upstream fell through to the NGINX defaults `$host` / `$server_port`. The config now injects the observed values first and there is nothing to restore, so with `Host: Example.COM:8443` the upstream sees `Example.COM:8443` / `8443` where it used to see `example.com` / `1984`. Keeping it. It makes a trusted peer agree with an untrusted one, which has always produced the Host with its port and case -- the old asymmetry came from the code path being skipped, not from a decision. TEST 13 now uses a Host that carries a port and mixed case so the choice is asserted rather than hidden, and TEST 16 covers the neighbouring case of a trusted peer sending an empty header value, which the config likewise cannot distinguish from having sent nothing. Also documents, next to `trusted_addresses` and in the template, that reading `$http_x_forwarded_*` in the rewrite phase caches the client's raw value for the rest of the request. Nothing downstream derives from those variables, but an access log format that names them logs what the client sent; `$scheme`, `$apisix_observed_host` and `$apisix_observed_port` are the sanitized values. The comment on `restore_if_sent` claimed a parity that only ever held for `X-Forwarded-Proto`; corrected.
The neutralization moved into the NGINX configuration, so X-Forwarded-Proto/Host/Port are on `r->headers_in` before any Lua runs and `ngx.req.get_headers()` returns them. `log-util.get_full_log` reads that map, so every logger plugin now records three request headers it did not before. This is the intended shape rather than an accident: the gateway does put those headers on the request, and a log that omits them is describing a request that was never made. It follows the same model Envoy uses -- sanitize once, on the way in, and let filters, logs and the upstream all read one value -- and it is what apache#12551 asked for when a plugin was found reading a forged X-Forwarded-Proto. The four assertions here pin the full header set, so they are updated from the payload the gateway actually emits. The delta against the previous expectation is exactly the three headers and nothing else.
`$apisix_observed_host` and `$apisix_observed_port` hold exactly what `$var_x_forwarded_host` and `$var_x_forwarded_port` held before -- the value X-Forwarded-Host and X-Forwarded-Port are given -- so there is no reason to invent a second name for it. Reusing the existing one also keeps the vocabulary of this file recognisable to anyone diffing it against APISIX 3.2. The two are the same length, so nothing about the rendered config's byte layout changes.
membphis
left a comment
There was a problem hiding this comment.
[P2] Preserve access to the original X-Forwarded-For chain
The old untrusted-peer path stores api_ctx.var.original_x_forwarded_for before clearing X-Forwarded-For. This change removes that field and still clears the header before plugins run, while the new $apisix_orig_* variables preserve proto, host, port, and Forwarded but not XFF. Out-of-tree audit or security plugins therefore lose the raw chain with no migration path. Please retain the compatibility field or expose and document an equivalent original-XFF variable, with a regression covering plugin access after sanitization.
X-Forwarded-For is the one header this change clears rather than overwrites: a peer outside a configured trust boundary loses the inbound chain entirely, so the upstream sees only the connection address. The Lua-only implementation kept a copy in `ctx.var.original_x_forwarded_for` for plugins that need the raw chain -- audit and security plugins mainly -- and dropping that field left them with nothing, while proto, host, port and Forwarded all kept a `$apisix_orig_*` copy. The asymmetry was an oversight: the `set` for XFF was removed as unused, when it was the one that mattered most. It is restored, and all five are now documented next to `trusted_addresses` as the replacement for `ctx.var.original_x_forwarded_*` -- reachable from a log format or from Lua as `ctx.var.apisix_orig_xf_*`. Restoring the field under its old name instead would mean writing it on every request from Lua, on the default path, which is the work this change exists to remove. A config-level variable costs one rewrite-phase assignment in C and is symmetric with the other four. Reading `$http_x_forwarded_for` in the rewrite phase indexes it, so it keeps the client's raw value for the rest of the request. That does not weaken the sanitization: `$proxy_add_x_forwarded_for` builds its value from `r->headers_in.x_forwarded_for` directly rather than from the variable, so the upstream still receives only the connection address. TEST 17 pins both halves -- the plugin reads the original chain, the upstream does not.
|
Good catch, and the asymmetry was an oversight rather than a decision — thank you. X-Forwarded-For is the one header this change clears rather than overwrites, so it is the one where the original is actually lost, and it was the one whose set $apisix_orig_xf_proto $http_x_forwarded_proto;
set $apisix_orig_xf_host $http_x_forwarded_host;
set $apisix_orig_xf_port $http_x_forwarded_port;
set $apisix_orig_forwarded $http_forwarded;
set $apisix_orig_xf_for $http_x_forwarded_for;Readable from a log format, or from Lua as On retaining One thing worth recording, since it looks like it should be a problem and is not: reading TEST 17 covers the regression you asked for, pinning both halves at once: with |
Description
handle_x_forwarded_headersruns on every request to overwriteX-Forwarded-Proto/Host/Portand clearForwarded, andset_upstream_x_forwarded_headersthen copies the result into$var_x_forwarded_*forproxy_set_header. Both do work the configuration can do in C, and both run on the path that matters most: with noapisix.trusted_addressesset — the default — no peer is trusted, so every request takes the same branch.Config side.
more_set_input_headersneutralizesr->headers_inin the rewrite phase, and two maps derive the observed host and port from the Host header:The upstream-facing
proxy_set_header X-Forwarded-Proto/Host/Portare removed, along with$var_x_forwarded_*.r->headers_inalready holds the values the request should carry andproxy_passforwards it as it stands, so there is nothing left to copy — and nothing that can overwrite a plugin's rewrite of those headers, which is what the Lua copier existed to preserve.proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_forstays: only that variable appends the connection address.Lua side. What is left needs a trust decision, so it stays in Lua behind a check that is a constant for the worker's lifetime:
With no
trusted_addressesconfigured this returns on its first line. When a boundary does exist, a trusted peer's values are restored from the$apisix_orig_*copies and an untrusted peer additionally loses the inboundX-Forwarded-Forchain.Two details worth calling out for review:
set $apisix_orig_*copies are rendered unconditionally rather than behind a template guard ontrusted_addresses. The guard is tempting — the copies are only ever read when a boundary exists — but it makes correctness depend on the CLI seeing the same configuration the worker will, which is not guaranteed for a deployment whose configuration can arrive after render time. Getting it wrong is silent and inverts the trust semantics, so the foursetdirectives are always emitted.$http_x_forwarded_*in the rewrite phase caches the pre-neutralization value for the rest of the request. That is harmless here only because nothing downstream derives from those variables any more. It is the reason the upstream-facing headers must come fromr->headers_inrather than from a variable, and the comment in the template says so.Behaviour
Unchanged. Verified by running each new test case against the previous implementation and taking the expectation from what it produced.
One assertion moved:
t/core/trusted-addresses.tTEST 1 no longer expectstrusted_addresses_matcher is not initializedin the error log, because with no boundary configured the new code returns before consulting the matcher. The assertion is kept, inverted, in a--- no_error_logblock.ctx.var.original_x_forwarded_*is removed. It had no consumer in-tree, but it was an externally visible ctx variable — a custom plugin reading it will now seenil.var_x_forwarded_proto/port/hostare likewise dropped from the writable-variable list incore/ctx.lua; a plugin that wants to change what the upstream receives should usecore.request.set_header, which now works for these headers where before it was overwritten.Behaviour changes
Four. The first three are confined to a configured trust boundary or to config-level variable reads; the fourth is visible to anyone running a logger plugin. The default request path — no
apisix.trusted_addresses— is bit-identical to before.1. A trusted peer that sent no
X-Forwarded-Host/X-Forwarded-Port. The previous implementation skipped the rewrite entirely for a trusted peer, so those headers stayed absent and the upstream fell through to the NGINX defaults$host/$server_port. The config now injects the observed values first and there is nothing to restore. WithHost: Example.COM:8443:X-Forwarded-Hostexample.comExample.COM:8443X-Forwarded-Port19848443Example.COM:8443/8443Kept deliberately: it makes a trusted peer agree with an untrusted one, which has always produced the Host with its port and case. The old asymmetry came from the code path being skipped, not from a decision. TEST 13 asserts it with a Host that carries a port and mixed case. An empty header value is indistinguishable from an absent one to
set, so it lands in the same place — TEST 16.2.
$http_x_forwarded_proto/host/portand$http_forwardedat config level. Reading them in the rewrite phase indexes them, so they keep the client's raw value for the rest of the request. Nothing downstream derives from them — the upstream headers come fromr->headers_in, and Lua'sctx.var.http_x_forwarded_*re-reads it through the prefix handler — but an access log format that names them now logs what the client sent rather than the sanitized value.$http_x_forwarded_for, by far the most commonly logged, is not in thesetlist and is unaffected. Documented next totrusted_addresses, naming$scheme/$var_x_forwarded_host/$var_x_forwarded_portas the sanitized substitutes.3.
ctx.var.original_x_forwarded_{proto,host,port,for}replaced byctx.var.apisix_orig_xf_*. The old fields were written from Lua on every request on the default path, which is the work this change removes. The fiveset $apisix_orig_*directives hold the same values at config level, readable from a log format or asctx.var.apisix_orig_xf_proto/_host/_port/_forandctx.var.apisix_orig_forwarded. An out-of-tree plugin reading the old names will seeniland needs the rename; the mapping is documented next totrusted_addresses. X-Forwarded-For matters most here, since it is cleared rather than overwritten when a boundary is configured and the peer is outside it — TEST 17 pins that a plugin still reads the original chain while the upstream does not.var_x_forwarded_proto/port/hostare likewise gone from the writable-variable list incore/ctx.lua; a plugin that wants to change what the upstream receives should usecore.request.set_header, which now works for these headers where before it was overwritten.4. Logger plugins now record
X-Forwarded-Proto/Host/Port. The neutralization happens before any Lua runs, so those headers are onr->headers_inandngx.req.get_headers()returns them.log-util.get_full_logreads that map, so every logger plugin —loggly,http-logger,kafka-logger,splunk-hec-loggingand the rest — now emits three request headers it did not before. Anyone parsing those logs with a fixed schema should expect the extra fields.This is the intended shape rather than a side effect. The gateway does put those headers on the request; a log that omits them describes a request that was never made. It is the model Envoy uses — sanitize once on the way in, and let filters, access logs and the upstream all read one value — and it is what #12551 asked for when a plugin was found making a security decision from a forged
X-Forwarded-Proto. The alternative, sanitizing only the upstream copy, would leave every one of the ~100 plugins reading the client's raw value unless each is individually taught to ask for the trusted one.If the client's raw value is wanted for forensics, it is available without giving up the sanitization:
$apisix_orig_xf_proto,$apisix_orig_xf_host,$apisix_orig_xf_portand$apisix_orig_forwardedare rendered unconditionally and can be named in an access log format.t/core/trusted-addresses.tTEST 1 is modified rather than only added to: its--- error_logexpectation oftrusted_addresses_matcher is not initializedis inverted into a--- no_error_logblock, because with no boundary configured the new code returns before consulting the matcher. The assertion is kept, not dropped.Tests
t/core/trusted-addresses.tgains seven cases:Host: example.com:8443, no trust boundary →X-Forwarded-Host: example.com:8443,X-Forwarded-Port: 8443Hostheader → falls back to$hostX-Forwarded-*→ upstream still receives the observed valuesX-Forwarded-Proto: grpc, route rewrites it tohttpsviaproxy-rewrite→ upstream receiveshttpsForwarded,X-Forwarded-Forreduced to the connection addressX-Forwarded-Proto→ treated as not sent, gets the observed oneRan against a pristine
masterworktree for comparison:t/core/trusted-addresses.tt/core/request.tt/plugin/real-ip.tt/plugin/redirect.tt/plugin/proxy-rewrite2.tt/plugin/ip-restriction.tt/plugin/proxy-mirror2.tt/plugin/forward-auth.tt/plugin/proxy-rewrite3.tThe two remaining failures are pre-existing on
masterin this environment and unrelated.Checklist
docs/en/latest/plugins/real-ip.mdstill describes it accurately