perf(core): sanitize the X-Forwarded-* headers in the NGINX config - #13803
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.
`$apisix_orig_xf_*` carried a vendor prefix nothing else in this file carries -- `$var_x_forwarded_*` and `$upstream_*` manage without one -- and an abbreviation that saved four characters at the cost of being unreadable. Naming them `$original_x_forwarded_proto/host/port/for` and `$original_forwarded` fixes more than the spelling: those are the names the values were kept under before, as `ctx.var.original_x_forwarded_*`, so `ctx.var` resolves them to the new NGINX variables and a plugin reading the old names keeps working. What was a breaking change with a documented migration is now no change at all. TEST 17 reads `ctx.var.original_x_forwarded_for` and passes. The values are strictly more available than before: the Lua fields were only written for an untrusted peer, while the config writes them for every request. `$var_x_forwarded_host` / `$var_x_forwarded_port` are unrelated and stay -- they hold what APISIX observed, not what the client sent. There is no `$var_x_forwarded_proto`; the observed protocol is `$scheme`.
|
Follow-up on the naming, which turns this into a cleaner answer to your point than my last reply gave. The preserved values are now named after the fields they replace: set $original_x_forwarded_proto $http_x_forwarded_proto;
set $original_x_forwarded_host $http_x_forwarded_host;
set $original_x_forwarded_port $http_x_forwarded_port;
set $original_x_forwarded_for $http_x_forwarded_for;
set $original_forwarded $http_forwarded;Those are the names the values were already kept under as The values are also strictly more available than before: the Lua fields were only written for an untrusted peer, while the configuration writes them for every request, and they can now be named in an access log format as well. (The vendor prefix went too — nothing else in |
…clear
Review caught a hole I had missed and my own check had walked past.
`set $original_x_forwarded_for $http_x_forwarded_for;` looks like the other four
copies, but `$http_x_forwarded_for` is not the same kind of variable.
`$http_x_forwarded_proto` and friends resolve through NGINX's *prefix* table --
`ngx_http_add_variable` routes `NGX_HTTP_VAR_PREFIX` entries into
`prefix_variables` and never into `variables_keys`, so they are re-evaluated on
every read. `$http_x_forwarded_for` is a dedicated entry in
`ngx_http_core_variables[]`, so naming it in the configuration makes it indexed:
the rewrite-phase `set` pinned the client's value in `r->variables[]` for the
rest of the request, and the untrusted-peer clear could not dislodge it.
Everything reading the variable rather than the header therefore saw the value
the trust boundary exists to remove -- route and service `vars`, `limit-count`
and friends with `key_type: var`, `traffic-split`, any plugin using `ctx.var`.
Measured against master, an untrusted peer sending `X-Forwarded-For: 9.9.9.9`
with `trusted_addresses: 10.0.0.0/8`:
ctx.var.http_x_forwarded_for master: nil before this fix: 9.9.9.9
route vars http_x_forwarded_for == 9.9.9.9 master: 404 before: 200
The upstream was never affected -- `$proxy_add_x_forwarded_for` builds from
`r->headers_in.x_forwarded_for` directly -- which is why the suite stayed green
and why checking only the upstream, as I did, was not enough.
The configuration now declares the slot empty and Lua fills it in the one branch
that destroys the value, which is where the copy was needed anyway; it costs
nothing on the default path, where the branch is not reached.
`original_x_forwarded_for` joins the writable-variable list in `core/ctx.lua` so
the assignment reaches the NGINX variable and a log format can name it.
TEST 17 now asserts both halves -- the original chain is readable, the current
value is `nil` -- and TEST 18 pins it where it bites, a route matching on
`http_x_forwarded_for`, which returns 404 on master and on this branch. TEST 15
also gained the `X-Forwarded-Port` its name always claimed it sent.
The note next to `trusted_addresses` is rewritten against the corrected
behaviour: the four prefix variables are cached for config-level readers such as
an access log format, Lua always sees the overridden values, and
`$http_x_forwarded_for` is no longer affected at all.
membphis
left a comment
There was a problem hiding this comment.
[P2][non-blocking] Preserve original_x_forwarded_for on the default path
The current template initializes $original_x_forwarded_for to an empty value, and Lua populates it only when trusted_addresses is configured and the peer is untrusted. When trusted_addresses is not configured, handle_trusted_x_forwarded_headers returns immediately, so custom plugins or access logs reading ctx.var.original_x_forwarded_for no longer see the incoming X-Forwarded-For chain.
Please consider preserving the original XFF on this default path and adding a regression test for it. This does not block my approval; whether to address it in this PR is up to the author.
The copy was taken only in the branch that destroys the value -- a configured
trust boundary with the peer outside it. On the default path
`handle_trusted_x_forwarded_headers` returns on its first line, so
`ctx.var.original_x_forwarded_for` stayed empty where the Lua-only
implementation had filled it.
Nothing was lost there, since X-Forwarded-For is not cleared without a boundary
and `ctx.var.http_x_forwarded_for` still holds the chain, but a plugin written
against the old field reads the wrong thing, and that difference was neither
intended nor documented. Measured against master, a request carrying
`X-Forwarded-For: 9.9.9.9` with no `trusted_addresses`:
ctx.var.original_x_forwarded_for master: 9.9.9.9 before this fix: ""
The copy now happens on every path, before the trust check. It costs one
`ctx.var` read and, when the client sent the header, one write; the other four
originals are taken by the configuration and are unaffected. A trusted peer now
gets a copy as well, which the Lua-only implementation did not take -- consistent
with the other four, which the configuration copies for every peer.
TEST 19 covers the default path.
apache#13753 reports that `X-Forwarded-Host` cannot be removed or overridden per route. The `headers.set` half was already covered for X-Forwarded-Proto and X-Forwarded-Port, but nothing covered `headers.remove`, which is what the reporter reached for first and the case that was broken: `set_upstream_x_forwarded_headers` only assigned `$var_x_forwarded_host` when `ctx.var.http_x_forwarded_host` was non-nil, so removing the header skipped the assignment, the variable kept its `set $var_x_forwarded_host $host;` default, and `proxy_set_header` sent the original value anyway. TEST 9 is the reporter's configuration exactly -- `headers.remove` with no `trusted_addresses` -- and TEST 10 is the same from a trusted client, since the two take different paths through the restore logic. Both fail against the previous implementation and pass here. TEST 11 covers overriding the header to a value. It passes both before and after, so it is coverage rather than a regression guard: it pins that removing the `proxy_set_header` directives did not cost the case that already worked.
Description
Fixes #13753.
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$original_*copies and an untrusted peer additionally loses the inboundX-Forwarded-Forchain.Two details worth calling out for review:
set $original_*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 fivesetdirectives 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.Fixes #13753 — plugins could not remove or override X-Forwarded-Host
location /carriedproxy_set_header X-Forwarded-Host $var_x_forwarded_host;, and$var_x_forwarded_hostwas populated byset_upstream_x_forwarded_headers, which only assigned it whenctx.var.http_x_forwarded_hostwas non-nil.proxy-rewrite'sheaders.removesets the header to nil, so the assignment was skipped, the variable kept itsset $var_x_forwarded_host $host;default, and the original value went upstream anyway. The plugin did remove the header fromr->headers_in;proxy_set_headerput a value back afterwards.Removing those three
proxy_set_headerdirectives is what this change needed for its own reasons, and it fixes that as a consequence. Measured, upstream echoing what it received:headers.remove: ["X-Forwarded-Host"]x-forwarded-host: localhostheaders.set: {"X-Forwarded-Host": "my-upstream.example.com"}x-forwarded-host: localhostx-forwarded-host: my-upstream.example.comThe same mechanism was breaking
t/plugin/proxy-rewrite2.tTESTs 4/5/7 andproxy-rewrite3.t13/34 during development, which is how it was found.Note that approaches 5 and 6 in that issue —
ngx.var.var_x_forwarded_host = ''from abefore_proxyplugin — do work today and stop working here; see behaviour change 5 below.Behaviour changes
Five. 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. Config-level readers of
$http_x_forwarded_proto/host/portand$http_forwarded. Naming them in an access log format, anif, or amapreads the value cached when the override was applied — what the client sent, not the override. Those four are prefix variables, so Lua reads (core.request.header,ctx.var.http_x_forwarded_*) are re-evaluated and always see the overridden values.$http_x_forwarded_foris deliberately not named in the configuration and is unaffected in either direction.$scheme,$var_x_forwarded_hostand$var_x_forwarded_portare the overridden values at config level.3.
ctx.var.original_x_forwarded_{proto,host,port,for}now come from the configuration. They were written from Lua on every request on the default path, which is the work this change removes. Fiveset $original_*directives hold the same values at config level, soctx.var.original_x_forwarded_proto/_host/_port/_forresolve to them and a plugin reading those names is unaffected.$original_forwardedis new. The values are in fact more available than before: the Lua fields were only written for an untrusted peer, the config writes them for every request. They are also readable from an access log format. This matters most for X-Forwarded-For, which 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.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:
$original_x_forwarded_proto,$original_x_forwarded_host,$original_x_forwarded_port,$original_x_forwarded_forand$original_forwardedare rendered unconditionally and can be named in an access log format.5.
$var_x_forwarded_hostand$var_x_forwarded_portare no longer writable, and$var_x_forwarded_protono longer exists. They weresetvariables feedingproxy_set_header; they are now map outputs feedingmore_set_input_headers, so assigning them from Lua is a silent no-op and naming$var_x_forwarded_protoin anaccess_log_formator a config snippet fails at startup withunknown "var_x_forwarded_proto" variable.This closes off a workaround that #13753 lists —
ngx.var.var_x_forwarded_host = ''in abefore_proxyplugin, which does clear the header on master — while fixing the two approaches that issue actually asks for. Measured,proxy-rewriteagainst a route:headers.remove: ["X-Forwarded-Host"]headers.set: {"X-Forwarded-Host": "..."}ngx.var.var_x_forwarded_host = ''inbefore_proxyThe right way to change what the upstream receives is now
core.request.set_header, which works because nothing overwritesr->headers_inafterwards.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
Eleven cases added. Every expectation was taken from what the previous implementation produced for the same request, rather than written from the specification; the ones marked guard fail against it and pass here.
t/core/trusted-addresses.t:Host: example.com:8443, no trust boundary →X-Forwarded-Host: example.com:8443,X-Forwarded-Port: 8443Hostheader → falls back to$hostX-Forwarded-*,Hostcarrying a port and mixed case → pins behaviour change 1X-Forwarded-Proto: grpc, rewritten tohttpsbyproxy-rewrite→ upstream receiveshttpsForwarded,X-Forwarded-Forreduced to the connection addressX-Forwarded-Proto→ treated as not sentctx.var.http_x_forwarded_forisnil, the upstream does not see ithttp_x_forwarded_fordoes not see the cleared chain (404, as before)t/plugin/proxy-rewrite2.t, for #13753:headers.remove: ["X-Forwarded-Host"]with notrusted_addresses, the reporter's configurationX-Forwarded-Hostto a value; passes before and after, so it pins that removing theproxy_set_headerdirectives did not cost the case that already workedThe suites that exercise this code —
t/core/trusted-addresses.t,t/core/request.t,t/plugin/proxy-rewrite2.t,proxy-rewrite3.t,real-ip.t,redirect.t,ip-restriction.t,proxy-mirror2.t,loggly.t,forward-auth.t— were also run against a worktree at the merge base and compared, rather than judged on their own.Checklist
docs/en/latest/plugins/real-ip.mdstill describes it accurately