From 240648c4120572607f9b1b68629d58e459f9fc7d Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 11 Aug 2026 08:14:44 +0800 Subject: [PATCH 1/9] perf(core): sanitize the X-Forwarded-* headers in the NGINX config `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`. --- apisix/cli/ngx_tpl.lua | 53 ++++++++-- apisix/core/ctx.lua | 3 - apisix/init.lua | 125 +++++++++-------------- t/APISIX.pm | 29 ++++-- t/core/trusted-addresses.t | 200 +++++++++++++++++++++++++++++++++++++ 5 files changed, 308 insertions(+), 102 deletions(-) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 4fa56d57a4cc..44faeb29a07a 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -273,6 +273,25 @@ stream { {% if enable_http then %} http { + # X-Forwarded-* sanitization, first half. The second is + # `handle_trusted_x_forwarded_headers` in apisix/init.lua. + # + # Every request is neutralized unconditionally, in the rewrite phase, in C. + # That is the case worth optimizing for: with no `apisix.trusted_addresses` + # configured no peer is trusted, so it is what every request gets. + # + # the port carried by the Host header, falling back to the listener's own + map $http_host $apisix_observed_port { + default $server_port; + "~:(?

\\d+)$" $p; + } + # `$http_host` rather than `$host`: the port the client connected to belongs + # in X-Forwarded-Host, and `$host` drops it + map $http_host $apisix_observed_host { + default $http_host; + "" $host; + } + # put extra_lua_path in front of the builtin path # so user can override the source code lua_package_path "{*extra_lua_path*}$prefix/deps/share/lua/5.1/?.lua;$prefix/deps/share/lua/5.1/?/init.lua;]=] @@ -880,14 +899,25 @@ http { ### the following x-forwarded-* headers is to send to upstream server - set $var_x_forwarded_proto $scheme; - set $var_x_forwarded_host $host; - set $var_x_forwarded_port $server_port; - + # Take copies before neutralizing, so a trusted peer's own values can + # be put back. ngx_rewrite's `set` runs before headers_more's handler, + # which is what makes this ordering work -- do not reorder these. + 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; + more_set_input_headers "X-Forwarded-Proto: $scheme"; + more_set_input_headers "X-Forwarded-Host: $apisix_observed_host"; + more_set_input_headers "X-Forwarded-Port: $apisix_observed_port"; + more_set_input_headers "Forwarded: "; + + # X-Forwarded-Proto/Host/Port are not set here: `r->headers_in` already + # holds the values this request should carry, and proxy_pass forwards it + # as it stands. That is also what lets a plugin rewrite them -- a + # `proxy_set_header` would overwrite the plugin's value with whatever the + # variable held. X-Forwarded-For is different: the connection address has + # to be appended, which only $proxy_add_x_forwarded_for does. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $var_x_forwarded_proto; - proxy_set_header X-Forwarded-Host $var_x_forwarded_host; - proxy_set_header X-Forwarded-Port $var_x_forwarded_port; {% if enabled_plugins["proxy-cache"] or enabled_plugins["graphql-proxy-cache"] then %} ### the following configuration is to cache response content from upstream server @@ -1000,10 +1030,13 @@ http { proxy_set_header X-Real-IP $remote_addr; proxy_pass_header Date; + # X-Forwarded-Proto/Host/Port are not set here: `r->headers_in` already + # holds the values this request should carry, and proxy_pass forwards it + # as it stands. That is also what lets a plugin rewrite them -- a + # `proxy_set_header` would overwrite the plugin's value with whatever the + # variable held. X-Forwarded-For is different: the connection address has + # to be appended, which only $proxy_add_x_forwarded_for does. proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $var_x_forwarded_proto; - proxy_set_header X-Forwarded-Host $var_x_forwarded_host; - proxy_set_header X-Forwarded-Port $var_x_forwarded_port; proxy_pass $upstream_scheme://apisix_backend$upstream_uri; diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua index 64d1093312db..56168104a763 100644 --- a/apisix/core/ctx.lua +++ b/apisix/core/ctx.lua @@ -249,9 +249,6 @@ do rate_limiting_info = true, - var_x_forwarded_proto = true, - var_x_forwarded_port = true, - var_x_forwarded_host = true, } -- sort in alphabetical diff --git a/apisix/init.lua b/apisix/init.lua index 13a8cf8a90b0..1a932fd13f4e 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -686,88 +686,59 @@ function _M.handle_upstream(api_ctx, route, enable_websocket) end -local function handle_x_forwarded_headers(api_ctx) - local addr_is_trusted = trusted_addresses_util.is_trusted(api_ctx.var.realip_remote_addr) - - -- Only untrusted values need to be overwritten or cleared. - if not addr_is_trusted then - -- store the original x-forwarded-* headers - -- to allow future use by other plugins or processes - api_ctx.var.original_x_forwarded_proto = api_ctx.var.http_x_forwarded_proto - api_ctx.var.original_x_forwarded_host = api_ctx.var.http_x_forwarded_host - api_ctx.var.original_x_forwarded_port = api_ctx.var.http_x_forwarded_port - api_ctx.var.original_x_forwarded_for = api_ctx.var.http_x_forwarded_for - - -- trusted ones - -- ref: ngx_tpl.lua#L831-L840 - -- - -- these values are observed directly by APISIX and cannot be forged, - -- making them highly credible. - local proto = api_ctx.var.scheme - local http_host = api_ctx.var.http_host or api_ctx.var.host - -- parse_addr handles IPv6 literals and bracketed host:port correctly. - local _, port_from_host = core.utils.parse_addr(http_host) - local host = http_host - local port = port_from_host or api_ctx.var.server_port - - -- override the x-forwarded-* headers to the trusted ones. - -- make sure that the correct values ​​are obtained - -- in the subsequent stages using `core.request.header`. - core.request.set_header(api_ctx, "X-Forwarded-Proto", proto) - core.request.set_header(api_ctx, "X-Forwarded-Host", host) - core.request.set_header(api_ctx, "X-Forwarded-Port", port) - -- Clear RFC 7239 Forwarded header to prevent forgery. - core.request.set_header(api_ctx, "Forwarded", nil) - - -- X-Forwarded-For: when a trust boundary is configured but this peer is - -- untrusted, reset it so the upstream only sees the APISIX-observed - -- connection IP via `$proxy_add_x_forwarded_for`, dropping the spoofable - -- inbound chain. When `trusted_addresses` is unset, keep the compatible - -- default of preserving the inbound chain (the connection IP is appended). - if trusted_addresses_util.is_configured() then - core.request.set_header(api_ctx, "X-Forwarded-For", nil) - api_ctx.var.http_x_forwarded_for = nil - end - - -- update the cached value in http_x_forwarded_* to the trusted ones. - -- make sure that the correct values ​​are obtained - -- in the subsequent stages using `var.http_x_forwarded_*`. - api_ctx.var.http_x_forwarded_proto = proto - api_ctx.var.http_x_forwarded_host = host - api_ctx.var.http_x_forwarded_port = port - api_ctx.var.http_forwarded = nil +-- X-Forwarded-Proto/Host/Port and Forwarded are already neutralized by the time +-- this runs: `more_set_input_headers` in apisix/cli/ngx_tpl.lua does it in the +-- rewrite phase, in C, on every request. That is unconditional because with no +-- trust boundary configured -- the default -- it is what every request needs, and +-- keeping it in the config keeps Lua off that path entirely. +-- +-- What is left needs a trust decision, so it stays here, behind a check that is a +-- constant for the worker's lifetime: with no `trusted_addresses` this returns on +-- its first line and nothing else runs. +-- +-- `set` captures an absent header as the empty string, so "" means the peer sent +-- nothing and the value the config injected stays -- which is the value the +-- upstream has always received for a peer that sent no X-Forwarded-Proto. +-- `ctx.var.http_x_forwarded_*` is updated alongside, so a plugin reading it in a +-- later phase sees the restored value rather than the injected one. +local function restore_if_sent(api_ctx, header_name, var_name, orig) + if not orig or orig == "" then + return end + + core.request.set_header(api_ctx, header_name, orig) + api_ctx.var[var_name] = orig end --- in ngx_tpl.lua#L831-L840, --- there is such code: `proxy_set_header X-Forwarded-XXX $var_x_forwarded_xxx;` --- that is, set the `X-Forwarded-XXX` header through `var_x_forwarded_xxx`. --- --- therefore, it is necessary to set the trusted `http_x_forwarded_xxx` to `var_x_forwarded_xxx`. --- So that the `X-Forwarded-XXX` header is updated to a trusted value. --- --- currently, only following headers are updated through these variables: --- - X-Forwarded-Proto --- - X-Forwarded-Port --- - X-Forwarded-Host --- --- the `X-Forwarded-For` header is not updated through these variables. --- because it is set by the `proxy_add_x_forwarded_for` directive. -local function set_upstream_x_forwarded_headers(api_ctx) - local proto = api_ctx.var.http_x_forwarded_proto - if proto then - api_ctx.var.var_x_forwarded_proto = proto +local function handle_trusted_x_forwarded_headers(api_ctx) + if not trusted_addresses_util.is_configured() then + return end - local port = api_ctx.var.http_x_forwarded_port - if port then - api_ctx.var.var_x_forwarded_port = port + if trusted_addresses_util.is_trusted(api_ctx.var.realip_remote_addr) then + -- a trusted peer's own values go back, from the copies the config took + -- before overwriting them + restore_if_sent(api_ctx, "X-Forwarded-Proto", "http_x_forwarded_proto", + api_ctx.var.apisix_orig_xf_proto) + restore_if_sent(api_ctx, "X-Forwarded-Host", "http_x_forwarded_host", + api_ctx.var.apisix_orig_xf_host) + restore_if_sent(api_ctx, "X-Forwarded-Port", "http_x_forwarded_port", + api_ctx.var.apisix_orig_xf_port) + restore_if_sent(api_ctx, "Forwarded", "http_forwarded", + api_ctx.var.apisix_orig_forwarded) + + return end - local host = api_ctx.var.http_x_forwarded_host - if host then - api_ctx.var.var_x_forwarded_host = host + -- An untrusted peer, with a trust boundary to measure it against: drop the + -- inbound X-Forwarded-For so the upstream only sees the connection IP via + -- `$proxy_add_x_forwarded_for`. Without a boundary the chain is preserved, + -- which is the compatible default and is why this lives behind the check + -- above rather than in the config. + if api_ctx.var.http_x_forwarded_for then + core.request.set_header(api_ctx, "X-Forwarded-For", nil) + api_ctx.var.http_x_forwarded_for = nil end end @@ -828,7 +799,7 @@ function _M.http_access_phase() -- var.request is read-only; copy to a writable variable so data-mask can redact query params api_ctx.var.request_line = api_ctx.var.request - handle_x_forwarded_headers(api_ctx) + handle_trusted_x_forwarded_headers(api_ctx) -- When match_uri_encoded_slash is on, match the route against a uri that -- keeps the encoded slash (%2F) so it is treated as part of a path @@ -970,10 +941,6 @@ function _M.http_access_phase() end span:finish(ngx_ctx) - -- set before handle_upstream: grpc/dubbo/disable_proxy_buffering exit via - -- ngx.exec() and never return, so the trusted values must be applied first. - set_upstream_x_forwarded_headers(api_ctx) - _M.handle_upstream(api_ctx, route, enable_websocket) end diff --git a/t/APISIX.pm b/t/APISIX.pm index e0b86560b040..0ecb6727ffe7 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -238,9 +238,6 @@ my $disable_proxy_buffering_location = <<_EOC_; proxy_pass_header Date; proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$var_x_forwarded_proto; - proxy_set_header X-Forwarded-Host \$var_x_forwarded_host; - proxy_set_header X-Forwarded-Port \$var_x_forwarded_port; proxy_pass \$upstream_scheme://apisix_backend\$upstream_uri; mirror /proxy_mirror; @@ -626,6 +623,16 @@ _EOC_ $http_config .= <<_EOC_; $lua_deps_path + # mirrors apisix/cli/ngx_tpl.lua + map \$http_host \$apisix_observed_port { + default \$server_port; + "~:(?

\\d+)\$" \$p; + } + map \$http_host \$apisix_observed_host { + default \$http_host; + "" \$host; + } + lua_shared_dict plugin-limit-req 10m; lua_shared_dict plugin-limit-count 10m; lua_shared_dict plugin-limit-count-lock 10m; @@ -953,16 +960,18 @@ _EOC_ proxy_set_header X-Real-IP \$remote_addr; proxy_pass_header Date; - ### the following x-forwarded-* headers is to send to upstream server + 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; + more_set_input_headers "X-Forwarded-Proto: \$scheme"; + more_set_input_headers "X-Forwarded-Host: \$apisix_observed_host"; + more_set_input_headers "X-Forwarded-Port: \$apisix_observed_port"; + more_set_input_headers "Forwarded: "; - set \$var_x_forwarded_proto \$scheme; - set \$var_x_forwarded_host \$host; - set \$var_x_forwarded_port \$server_port; + ### the following x-forwarded-* headers is to send to upstream server proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto \$var_x_forwarded_proto; - proxy_set_header X-Forwarded-Host \$var_x_forwarded_host; - proxy_set_header X-Forwarded-Port \$var_x_forwarded_port; proxy_pass \$upstream_scheme://apisix_backend\$upstream_uri; mirror /proxy_mirror; diff --git a/t/core/trusted-addresses.t b/t/core/trusted-addresses.t index 233bca36ce0a..e3dd5cea771f 100644 --- a/t/core/trusted-addresses.t +++ b/t/core/trusted-addresses.t @@ -68,6 +68,7 @@ x-forwarded-proto: http x-real-ip: 127.0.0.1 --- error_log trusted_addresses is not configured +--- no_error_log trusted_addresses_matcher is not initialized @@ -438,3 +439,202 @@ x-forwarded-host: example.com x-forwarded-port: 8443 x-forwarded-proto: https x-real-ip: 127.0.0.1 + + + +=== TEST 11: Host carrying a port sets X-Forwarded-Host and X-Forwarded-Port from it +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- more_headers +Host: example.com:8443 +--- response_body +uri: /old_uri +host: example.com:8443 +x-forwarded-for: 127.0.0.1 +x-forwarded-host: example.com:8443 +x-forwarded-port: 8443 +x-forwarded-proto: http +x-real-ip: 127.0.0.1 +--- error_log +trusted_addresses is not configured +--- no_error_log +trusted_addresses_matcher is not initialized + + + +=== TEST 12: request without a Host header falls back to $host +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin + pass_host: rewrite + upstream_host: localhost +#END +--- raw_request eval +"GET /old_uri HTTP/1.0\r\n\r\n" +--- response_body +uri: /old_uri +host: localhost +x-forwarded-for: 127.0.0.1 +x-forwarded-host: localhost +x-forwarded-port: 1984 +x-forwarded-proto: http +x-real-ip: 127.0.0.1 +--- error_log +trusted_addresses is not configured +--- no_error_log +trusted_addresses_matcher is not initialized + + + +=== TEST 13: trusted client that sent no X-Forwarded-* still gets the observed values +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false + trusted_addresses: + - "127.0.0.1" +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- response_body +uri: /old_uri +host: localhost +x-forwarded-for: 127.0.0.1 +x-forwarded-host: localhost +x-forwarded-port: 1984 +x-forwarded-proto: http +x-real-ip: 127.0.0.1 +--- no_error_log +trusted_addresses is not configured +trusted_addresses_matcher is not initialized + + + +=== TEST 14: trusted client, proxy-rewrite of X-Forwarded-Proto reaches the upstream +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false + trusted_addresses: + - "127.0.0.1" +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + plugins: + proxy-rewrite: + headers: + X-Forwarded-Proto: https + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- more_headers +X-Forwarded-Proto: grpc +--- response_body +uri: /old_uri +host: localhost +x-forwarded-for: 127.0.0.1 +x-forwarded-host: localhost +x-forwarded-port: 1984 +x-forwarded-proto: https +x-real-ip: 127.0.0.1 +--- no_error_log +trusted_addresses is not configured +trusted_addresses_matcher is not initialized + + + +=== TEST 15: client not in trusted list, every forged forwarding header is dropped +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false + trusted_addresses: + - "1.0.0.1" + - "10.0.0.0/8" +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- more_headers +X-Forwarded-For: 9.9.9.9 +X-Forwarded-Proto: https +X-Forwarded-Host: evil.com +Forwarded: for=1.2.3.4 +--- response_body +uri: /old_uri +host: localhost +x-forwarded-for: 127.0.0.1 +x-forwarded-host: localhost +x-forwarded-port: 1984 +x-forwarded-proto: http +x-real-ip: 127.0.0.1 +--- no_error_log +trusted_addresses is not configured +trusted_addresses_matcher is not initialized From 22c3158d0f1f7e1d8bd28a5df5b49dfae421016e Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 11 Aug 2026 09:38:25 +0800 Subject: [PATCH 2/9] test: render the observed-port map identically in the harness 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. --- t/APISIX.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/APISIX.pm b/t/APISIX.pm index 0ecb6727ffe7..41e2317bbe20 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -626,7 +626,7 @@ _EOC_ # mirrors apisix/cli/ngx_tpl.lua map \$http_host \$apisix_observed_port { default \$server_port; - "~:(?

\\d+)\$" \$p; + "~:(?

\\\\d+)\$" \$p; } map \$http_host \$apisix_observed_host { default \$http_host; From c54181660496f84c43ce93569e9144cf4a6e9014 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 11 Aug 2026 10:04:06 +0800 Subject: [PATCH 3/9] fix(core): pin and document the trusted-peer behaviour change 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. --- apisix/cli/ngx_tpl.lua | 8 +++++++ apisix/init.lua | 12 ++++++---- conf/config.yaml.example | 8 +++++++ t/core/trusted-addresses.t | 48 ++++++++++++++++++++++++++++++++++---- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 44faeb29a07a..1a047ffec5b2 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -902,6 +902,14 @@ http { # Take copies before neutralizing, so a trusted peer's own values can # be put back. ngx_rewrite's `set` runs before headers_more's handler, # which is what makes this ordering work -- do not reorder these. + # + # Reading `$http_x_forwarded_*` here 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 from `r->headers_in` + # and Lua's `ctx.var.http_x_forwarded_*` re-reads it through the prefix + # handler -- but an access log format that names them logs what the + # client sent. `$scheme` / `$apisix_observed_host` / + # `$apisix_observed_port` are the sanitized values. 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; diff --git a/apisix/init.lua b/apisix/init.lua index 1a932fd13f4e..f2403f6a04f0 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -697,10 +697,14 @@ end -- its first line and nothing else runs. -- -- `set` captures an absent header as the empty string, so "" means the peer sent --- nothing and the value the config injected stays -- which is the value the --- upstream has always received for a peer that sent no X-Forwarded-Proto. --- `ctx.var.http_x_forwarded_*` is updated alongside, so a plugin reading it in a --- later phase sees the restored value rather than the injected one. +-- nothing and the value the config injected stays. That is a deliberate change +-- for a trusted peer: the Lua-only implementation skipped the whole rewrite for +-- one, so a header it did not send stayed absent and the upstream fell through to +-- `$host` / `$server_port`. A trusted peer now gets the same observed values an +-- untrusted one does -- the Host with its port and case, rather than the +-- lower-cased portless `$host` -- which is the value the untrusted path has always +-- produced. `ctx.var.http_x_forwarded_*` is updated alongside, so a plugin reading +-- it in a later phase sees the restored value rather than the injected one. local function restore_if_sent(api_ctx, header_name, var_name, orig) if not orig or orig == "" then return diff --git a/conf/config.yaml.example b/conf/config.yaml.example index bcc5e6acb423..427030f62d95 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -168,6 +168,14 @@ apisix: # address, it is reset so the upstream only sees the APISIX-observed # connection IP; when `trusted_addresses` is not configured, it is # preserved and the connection IP is appended (compatible default). + # The override happens in the NGINX configuration, before any Lua runs. + # A side effect: `$http_x_forwarded_proto/host/port` and `$http_forwarded` + # are read there, so they cache the client's raw value for the rest of the + # request. An access log format or a config-level `if` that reads them sees + # what the client sent, not the override. Use `$scheme`, + # `$apisix_observed_host` and `$apisix_observed_port` for the overridden + # values; `core.request.header` and `ctx.var.http_x_forwarded_*` in Lua are + # unaffected and always see the overridden ones. # fine tune the parameters of LRU cache for some features like secret lru: secret: diff --git a/t/core/trusted-addresses.t b/t/core/trusted-addresses.t index e3dd5cea771f..76cb0d3a8b21 100644 --- a/t/core/trusted-addresses.t +++ b/t/core/trusted-addresses.t @@ -518,7 +518,7 @@ trusted_addresses_matcher is not initialized -=== TEST 13: trusted client that sent no X-Forwarded-* still gets the observed values +=== TEST 13: trusted client that sent no X-Forwarded-* gets the observed values --- yaml_config apisix: node_listen: 1984 @@ -541,12 +541,14 @@ routes: #END --- request GET /old_uri +--- more_headers +Host: Example.COM:8443 --- response_body uri: /old_uri -host: localhost +host: Example.COM:8443 x-forwarded-for: 127.0.0.1 -x-forwarded-host: localhost -x-forwarded-port: 1984 +x-forwarded-host: Example.COM:8443 +x-forwarded-port: 8443 x-forwarded-proto: http x-real-ip: 127.0.0.1 --- no_error_log @@ -638,3 +640,41 @@ x-real-ip: 127.0.0.1 --- no_error_log trusted_addresses is not configured trusted_addresses_matcher is not initialized + + + +=== TEST 16: trusted client sending an empty X-Forwarded-Proto gets the observed one +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false + trusted_addresses: + - "127.0.0.1" +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- more_headers +X-Forwarded-Proto: +--- response_body +uri: /old_uri +host: localhost +x-forwarded-for: 127.0.0.1 +x-forwarded-host: localhost +x-forwarded-port: 1984 +x-forwarded-proto: http +x-real-ip: 127.0.0.1 +--- no_error_log +[error] From 283c97535c989b79ae2bc5109361c63f9ab5839c Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 11 Aug 2026 12:53:18 +0800 Subject: [PATCH 4/9] test(loggly): record the X-Forwarded-* headers the gateway now adds 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/apisix#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. --- t/plugin/loggly.t | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/t/plugin/loggly.t b/t/plugin/loggly.t index c81206f16e5a..69c3ddc4279f 100644 --- a/t/plugin/loggly.t +++ b/t/plugin/loggly.t @@ -362,7 +362,7 @@ opentracing --- grep_error_log eval qr/message received: [ -~]+/ --- grep_error_log_out eval -qr/message received: <10>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[token-1\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*"\},"method":"GET","querystring":\{\},"size":[\d]+,"uri":"\/opentracing","url":"http:\/\/127\.0\.0\.1:1984\/opentracing"\},"response":\{"headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ +qr/message received: <10>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[token-1\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*","x-forwarded-host":"127\.0\.0\.1:1984","x-forwarded-port":"1984","x-forwarded-proto":"http"\},"method":"GET","querystring":\{\},"size":[\d]+,"uri":"\/opentracing","url":"http:\/\/127\.0\.0\.1:1984\/opentracing"\},"response":\{"headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ @@ -410,7 +410,7 @@ opentracing --- grep_error_log eval qr/message received: [ -~]+/ --- grep_error_log_out eval -qr/message received: <14>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[tok\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*"\},"method":"GET","querystring":\{\},"size":[\d]+,"uri":"\/opentracing","url":"http:\/\/127\.0\.0\.1:1984\/opentracing"\},"response":\{"body":"opentracing\\n","headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ +qr/message received: <14>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[tok\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*","x-forwarded-host":"127\.0\.0\.1:1984","x-forwarded-port":"1984","x-forwarded-proto":"http"\},"method":"GET","querystring":\{\},"size":[\d]+,"uri":"\/opentracing","url":"http:\/\/127\.0\.0\.1:1984\/opentracing"\},"response":\{"body":"opentracing\\n","headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ @@ -463,7 +463,7 @@ opentracing --- grep_error_log eval qr/message received: [ -~]+/ --- grep_error_log_out eval -qr/message received: <14>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[tok\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*"\},"method":"GET","querystring":\{"bar":"bar"\},"size":[\d]+,"uri":"\/opentracing\?bar=bar","url":"http:\/\/127\.0\.0\.1:1984\/opentracing\?bar=bar"\},"response":\{"body":"opentracing\\n","headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ +qr/message received: <14>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[tok\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*","x-forwarded-host":"127\.0\.0\.1:1984","x-forwarded-port":"1984","x-forwarded-proto":"http"\},"method":"GET","querystring":\{"bar":"bar"\},"size":[\d]+,"uri":"\/opentracing\?bar=bar","url":"http:\/\/127\.0\.0\.1:1984\/opentracing\?bar=bar"\},"response":\{"body":"opentracing\\n","headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ @@ -487,7 +487,7 @@ opentracing --- grep_error_log eval qr/message received: [ -~]+/ --- grep_error_log_out eval -qr/message received: <14>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[tok\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*"\},"method":"GET","querystring":\{"foo":"bar"\},"size":[\d]+,"uri":"\/opentracing\?foo=bar","url":"http:\/\/127\.0\.0\.1:1984\/opentracing\?foo=bar"\},"response":\{"headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ +qr/message received: <14>1 [\d\-T:.]+Z [\d.]+ apisix [\d]+ - \[tok\@41058 tag="apisix"] \{"apisix_latency":[\d.]*,"client_ip":"127\.0\.0\.1","latency":[\d.]*,"request":\{"headers":\{"content-type":"application\/x-www-form-urlencoded","host":"127\.0\.0\.1:1984","user-agent":"lua-resty-http\/[\d.]* \(Lua\) ngx_lua\/[\d]*","x-forwarded-host":"127\.0\.0\.1:1984","x-forwarded-port":"1984","x-forwarded-proto":"http"\},"method":"GET","querystring":\{"foo":"bar"\},"size":[\d]+,"uri":"\/opentracing\?foo=bar","url":"http:\/\/127\.0\.0\.1:1984\/opentracing\?foo=bar"\},"response":\{"headers":\{"connection":"close","content-type":"text\/plain","server":"APISIX\/[0-9A-Za-z._+-]+","transfer-encoding":"chunked"\},"size":[\d]*,"status":200\},"route_id":"1","server":\{"hostname":"[ -~]*","version":"[0-9A-Za-z._+-]+"\},"service_id":"","start_time":[\d]*,"upstream":"127\.0\.0\.1:1982","upstream_latency":[\d]*\}/ From fc16cfd4cbeded120b737b9a2ca23e3c995691b7 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Tue, 11 Aug 2026 14:11:56 +0800 Subject: [PATCH 5/9] change(core): keep the names the replaced set directives used `$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. --- apisix/cli/ngx_tpl.lua | 15 +++++++++------ conf/config.yaml.example | 2 +- t/APISIX.pm | 8 ++++---- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 1a047ffec5b2..b56def84c759 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -280,14 +280,17 @@ http { # That is the case worth optimizing for: with no `apisix.trusted_addresses` # configured no peer is trusted, so it is what every request gets. # + # These keep the names the `set` directives they replace used, and hold the + # same thing: what X-Forwarded-Host and X-Forwarded-Port are given below. + # # the port carried by the Host header, falling back to the listener's own - map $http_host $apisix_observed_port { + map $http_host $var_x_forwarded_port { default $server_port; "~:(?

\\d+)$" $p; } # `$http_host` rather than `$host`: the port the client connected to belongs # in X-Forwarded-Host, and `$host` drops it - map $http_host $apisix_observed_host { + map $http_host $var_x_forwarded_host { default $http_host; "" $host; } @@ -908,15 +911,15 @@ http { # derives from them -- the upstream headers come from `r->headers_in` # and Lua's `ctx.var.http_x_forwarded_*` re-reads it through the prefix # handler -- but an access log format that names them logs what the - # client sent. `$scheme` / `$apisix_observed_host` / - # `$apisix_observed_port` are the sanitized values. + # client sent. `$scheme` / `$var_x_forwarded_host` / + # `$var_x_forwarded_port` are the sanitized values. 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; more_set_input_headers "X-Forwarded-Proto: $scheme"; - more_set_input_headers "X-Forwarded-Host: $apisix_observed_host"; - more_set_input_headers "X-Forwarded-Port: $apisix_observed_port"; + more_set_input_headers "X-Forwarded-Host: $var_x_forwarded_host"; + more_set_input_headers "X-Forwarded-Port: $var_x_forwarded_port"; more_set_input_headers "Forwarded: "; # X-Forwarded-Proto/Host/Port are not set here: `r->headers_in` already diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 427030f62d95..3c288f5ed773 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -173,7 +173,7 @@ apisix: # are read there, so they cache the client's raw value for the rest of the # request. An access log format or a config-level `if` that reads them sees # what the client sent, not the override. Use `$scheme`, - # `$apisix_observed_host` and `$apisix_observed_port` for the overridden + # `$var_x_forwarded_host` and `$var_x_forwarded_port` for the overridden # values; `core.request.header` and `ctx.var.http_x_forwarded_*` in Lua are # unaffected and always see the overridden ones. # fine tune the parameters of LRU cache for some features like secret diff --git a/t/APISIX.pm b/t/APISIX.pm index 41e2317bbe20..056df8840bab 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -624,11 +624,11 @@ _EOC_ $lua_deps_path # mirrors apisix/cli/ngx_tpl.lua - map \$http_host \$apisix_observed_port { + map \$http_host \$var_x_forwarded_port { default \$server_port; "~:(?

\\\\d+)\$" \$p; } - map \$http_host \$apisix_observed_host { + map \$http_host \$var_x_forwarded_host { default \$http_host; "" \$host; } @@ -965,8 +965,8 @@ _EOC_ set \$apisix_orig_xf_port \$http_x_forwarded_port; set \$apisix_orig_forwarded \$http_forwarded; more_set_input_headers "X-Forwarded-Proto: \$scheme"; - more_set_input_headers "X-Forwarded-Host: \$apisix_observed_host"; - more_set_input_headers "X-Forwarded-Port: \$apisix_observed_port"; + more_set_input_headers "X-Forwarded-Host: \$var_x_forwarded_host"; + more_set_input_headers "X-Forwarded-Port: \$var_x_forwarded_port"; more_set_input_headers "Forwarded: "; ### the following x-forwarded-* headers is to send to upstream server From 612a826dc0022d0dfcfc377535ec36c0c68e0e8a Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 12 Aug 2026 10:19:14 +0800 Subject: [PATCH 6/9] fix(core): keep the client's original X-Forwarded-For reachable 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/cli/ngx_tpl.lua | 1 + conf/config.yaml.example | 8 +++++++ t/APISIX.pm | 1 + t/core/trusted-addresses.t | 44 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index b56def84c759..92e2b3a8afde 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -917,6 +917,7 @@ http { 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; more_set_input_headers "X-Forwarded-Proto: $scheme"; more_set_input_headers "X-Forwarded-Host: $var_x_forwarded_host"; more_set_input_headers "X-Forwarded-Port: $var_x_forwarded_port"; diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 3c288f5ed773..0cb8aca6044f 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -176,6 +176,14 @@ apisix: # `$var_x_forwarded_host` and `$var_x_forwarded_port` for the overridden # values; `core.request.header` and `ctx.var.http_x_forwarded_*` in Lua are # unaffected and always see the overridden ones. + # What the client actually sent stays reachable through + # `$apisix_orig_xf_proto`, `$apisix_orig_xf_host`, + # `$apisix_orig_xf_port`, `$apisix_orig_xf_for` and + # `$apisix_orig_forwarded`, in a log format or from Lua as + # `ctx.var.apisix_orig_xf_*`. These replace the + # `ctx.var.original_x_forwarded_*` fields, and cover + # X-Forwarded-For, which is cleared rather than overwritten + # when a trust boundary is configured and the peer is outside it. # fine tune the parameters of LRU cache for some features like secret lru: secret: diff --git a/t/APISIX.pm b/t/APISIX.pm index 056df8840bab..fdde07e5495a 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -964,6 +964,7 @@ _EOC_ 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; more_set_input_headers "X-Forwarded-Proto: \$scheme"; more_set_input_headers "X-Forwarded-Host: \$var_x_forwarded_host"; more_set_input_headers "X-Forwarded-Port: \$var_x_forwarded_port"; diff --git a/t/core/trusted-addresses.t b/t/core/trusted-addresses.t index 76cb0d3a8b21..db802bdc4737 100644 --- a/t/core/trusted-addresses.t +++ b/t/core/trusted-addresses.t @@ -678,3 +678,47 @@ x-forwarded-proto: http x-real-ip: 127.0.0.1 --- no_error_log [error] + + + +=== TEST 17: the client's original X-Forwarded-For stays readable after it is cleared +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false + trusted_addresses: + - "10.0.0.0/8" +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + plugins: + serverless-pre-function: + phase: access + functions: + - "return function(conf, ctx) ngx.log(ngx.WARN, \"orig xff: \", tostring(ctx.var.apisix_orig_xf_for)) end" + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- more_headers +X-Forwarded-For: 9.9.9.9, 8.8.8.8 +--- response_body +uri: /old_uri +host: localhost +x-forwarded-for: 127.0.0.1 +x-forwarded-host: localhost +x-forwarded-port: 1984 +x-forwarded-proto: http +x-real-ip: 127.0.0.1 +--- error_log +orig xff: 9.9.9.9, 8.8.8.8 + From 4f4617c67258aedfa05c97543267ff1939c8b648 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 12 Aug 2026 10:50:54 +0800 Subject: [PATCH 7/9] change(core): name the preserved values after the fields they replace `$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`. --- apisix/cli/ngx_tpl.lua | 10 +++++----- apisix/init.lua | 8 ++++---- conf/config.yaml.example | 15 ++++++++------- t/APISIX.pm | 10 +++++----- t/core/trusted-addresses.t | 2 +- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 92e2b3a8afde..7d6f761fb2b8 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -913,11 +913,11 @@ http { # handler -- but an access log format that names them logs what the # client sent. `$scheme` / `$var_x_forwarded_host` / # `$var_x_forwarded_port` are the sanitized values. - 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; + 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; more_set_input_headers "X-Forwarded-Proto: $scheme"; more_set_input_headers "X-Forwarded-Host: $var_x_forwarded_host"; more_set_input_headers "X-Forwarded-Port: $var_x_forwarded_port"; diff --git a/apisix/init.lua b/apisix/init.lua index f2403f6a04f0..f0c8ff930372 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -724,13 +724,13 @@ local function handle_trusted_x_forwarded_headers(api_ctx) -- a trusted peer's own values go back, from the copies the config took -- before overwriting them restore_if_sent(api_ctx, "X-Forwarded-Proto", "http_x_forwarded_proto", - api_ctx.var.apisix_orig_xf_proto) + api_ctx.var.original_x_forwarded_proto) restore_if_sent(api_ctx, "X-Forwarded-Host", "http_x_forwarded_host", - api_ctx.var.apisix_orig_xf_host) + api_ctx.var.original_x_forwarded_host) restore_if_sent(api_ctx, "X-Forwarded-Port", "http_x_forwarded_port", - api_ctx.var.apisix_orig_xf_port) + api_ctx.var.original_x_forwarded_port) restore_if_sent(api_ctx, "Forwarded", "http_forwarded", - api_ctx.var.apisix_orig_forwarded) + api_ctx.var.original_forwarded) return end diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 0cb8aca6044f..04e6e628d9f0 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -177,13 +177,14 @@ apisix: # values; `core.request.header` and `ctx.var.http_x_forwarded_*` in Lua are # unaffected and always see the overridden ones. # What the client actually sent stays reachable through - # `$apisix_orig_xf_proto`, `$apisix_orig_xf_host`, - # `$apisix_orig_xf_port`, `$apisix_orig_xf_for` and - # `$apisix_orig_forwarded`, in a log format or from Lua as - # `ctx.var.apisix_orig_xf_*`. These replace the - # `ctx.var.original_x_forwarded_*` fields, and cover - # X-Forwarded-For, which is cleared rather than overwritten - # when a trust boundary is configured and the peer is outside it. + # `$original_x_forwarded_proto`, `$original_x_forwarded_host`, + # `$original_x_forwarded_port`, `$original_x_forwarded_for` and + # `$original_forwarded`, in a log format or from Lua as + # `ctx.var.original_x_forwarded_*` -- the same names the values + # were previously kept under, so a plugin reading them is + # unaffected. This matters most for X-Forwarded-For, which is + # cleared rather than overwritten when a trust boundary is + # configured and the peer is outside it. # fine tune the parameters of LRU cache for some features like secret lru: secret: diff --git a/t/APISIX.pm b/t/APISIX.pm index fdde07e5495a..3d0520cc2f95 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -960,11 +960,11 @@ _EOC_ proxy_set_header X-Real-IP \$remote_addr; proxy_pass_header Date; - 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; + 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; more_set_input_headers "X-Forwarded-Proto: \$scheme"; more_set_input_headers "X-Forwarded-Host: \$var_x_forwarded_host"; more_set_input_headers "X-Forwarded-Port: \$var_x_forwarded_port"; diff --git a/t/core/trusted-addresses.t b/t/core/trusted-addresses.t index db802bdc4737..b9b52be201f9 100644 --- a/t/core/trusted-addresses.t +++ b/t/core/trusted-addresses.t @@ -701,7 +701,7 @@ routes: serverless-pre-function: phase: access functions: - - "return function(conf, ctx) ngx.log(ngx.WARN, \"orig xff: \", tostring(ctx.var.apisix_orig_xf_for)) end" + - "return function(conf, ctx) ngx.log(ngx.WARN, \"orig xff: \", tostring(ctx.var.original_x_forwarded_for)) end" upstream: nodes: "127.0.0.1:1980": 1 From 0f6c471584f4f4a64c769a73aa04fa3d7ed5e675 Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 12 Aug 2026 11:12:31 +0800 Subject: [PATCH 8/9] test: drop the trailing blank line reindex objects to --- t/core/trusted-addresses.t | 1 - 1 file changed, 1 deletion(-) diff --git a/t/core/trusted-addresses.t b/t/core/trusted-addresses.t index b9b52be201f9..8426628e904b 100644 --- a/t/core/trusted-addresses.t +++ b/t/core/trusted-addresses.t @@ -721,4 +721,3 @@ x-forwarded-proto: http x-real-ip: 127.0.0.1 --- error_log orig xff: 9.9.9.9, 8.8.8.8 - From 72300919a5572b420f006bfc57ad7e62eb3ddf5e Mon Sep 17 00:00:00 2001 From: AlinsRan Date: Wed, 12 Aug 2026 11:59:15 +0800 Subject: [PATCH 9/9] fix(core): do not pin X-Forwarded-For in the config, it survives the 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. --- apisix/cli/ngx_tpl.lua | 11 ++++++++++- apisix/core/ctx.lua | 2 ++ apisix/init.lua | 6 +++++- conf/config.yaml.example | 30 +++++++++++++++--------------- t/APISIX.pm | 2 +- t/core/trusted-addresses.t | 36 ++++++++++++++++++++++++++++++++++-- 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/apisix/cli/ngx_tpl.lua b/apisix/cli/ngx_tpl.lua index 7d6f761fb2b8..2f9a5c00510c 100644 --- a/apisix/cli/ngx_tpl.lua +++ b/apisix/cli/ngx_tpl.lua @@ -916,7 +916,16 @@ http { 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; + # X-Forwarded-For is the one that cannot be copied here. Unlike + # `$http_x_forwarded_proto` and friends, which are prefix variables and + # are re-evaluated on every read, `$http_x_forwarded_for` is a dedicated + # entry in `ngx_http_core_variables[]`; naming it in the configuration + # makes it indexed, and this `set` would then pin the client's value in + # `r->variables[]` for the whole request -- surviving the clear below and + # feeding it back to route `vars`, rate-limit keys and every other + # `ctx.var` reader. Lua fills the slot instead, in the one branch that + # destroys the value. + set $original_x_forwarded_for ''; set $original_forwarded $http_forwarded; more_set_input_headers "X-Forwarded-Proto: $scheme"; more_set_input_headers "X-Forwarded-Host: $var_x_forwarded_host"; diff --git a/apisix/core/ctx.lua b/apisix/core/ctx.lua index 56168104a763..12cdadc7dede 100644 --- a/apisix/core/ctx.lua +++ b/apisix/core/ctx.lua @@ -249,6 +249,8 @@ do rate_limiting_info = true, + original_x_forwarded_for = true, + } -- sort in alphabetical diff --git a/apisix/init.lua b/apisix/init.lua index f0c8ff930372..d8e47ea143dd 100644 --- a/apisix/init.lua +++ b/apisix/init.lua @@ -740,7 +740,11 @@ local function handle_trusted_x_forwarded_headers(api_ctx) -- `$proxy_add_x_forwarded_for`. Without a boundary the chain is preserved, -- which is the compatible default and is why this lives behind the check -- above rather than in the config. - if api_ctx.var.http_x_forwarded_for then + local inbound_xff = api_ctx.var.http_x_forwarded_for + if inbound_xff then + -- the config cannot take this copy without pinning the variable, so it is + -- taken here, where the value is about to be destroyed + api_ctx.var.original_x_forwarded_for = inbound_xff core.request.set_header(api_ctx, "X-Forwarded-For", nil) api_ctx.var.http_x_forwarded_for = nil end diff --git a/conf/config.yaml.example b/conf/config.yaml.example index 04e6e628d9f0..62a346639d93 100644 --- a/conf/config.yaml.example +++ b/conf/config.yaml.example @@ -169,21 +169,21 @@ apisix: # connection IP; when `trusted_addresses` is not configured, it is # preserved and the connection IP is appended (compatible default). # The override happens in the NGINX configuration, before any Lua runs. - # A side effect: `$http_x_forwarded_proto/host/port` and `$http_forwarded` - # are read there, so they cache the client's raw value for the rest of the - # request. An access log format or a config-level `if` that reads them sees - # what the client sent, not the override. Use `$scheme`, - # `$var_x_forwarded_host` and `$var_x_forwarded_port` for the overridden - # values; `core.request.header` and `ctx.var.http_x_forwarded_*` in Lua are - # unaffected and always see the overridden ones. - # What the client actually sent stays reachable through - # `$original_x_forwarded_proto`, `$original_x_forwarded_host`, - # `$original_x_forwarded_port`, `$original_x_forwarded_for` and - # `$original_forwarded`, in a log format or from Lua as - # `ctx.var.original_x_forwarded_*` -- the same names the values - # were previously kept under, so a plugin reading them is - # unaffected. This matters most for X-Forwarded-For, which is - # cleared rather than overwritten when a trust boundary is + # Lua always sees the overridden values -- `core.request.header` and + # `ctx.var.http_x_forwarded_*` alike. At config level it differs: + # naming `$http_x_forwarded_proto`, `$http_x_forwarded_host`, + # `$http_x_forwarded_port` or `$http_forwarded` in an access log + # format, an `if`, or a `map` reads the value cached when the + # override was applied, which is what the client sent. Use `$scheme`, + # `$var_x_forwarded_host` and `$var_x_forwarded_port` for the + # overridden ones. `$http_x_forwarded_for` is not affected. + # What the client sent is kept in `$original_x_forwarded_proto`, + # `$original_x_forwarded_host`, `$original_x_forwarded_port`, + # `$original_x_forwarded_for` and `$original_forwarded` -- readable + # from a log format, or from Lua as `ctx.var.original_x_forwarded_*`, + # the same names the values were previously kept under, so a plugin + # reading them is unaffected. This matters most for X-Forwarded-For, + # which is cleared rather than overwritten when a trust boundary is # configured and the peer is outside it. # fine tune the parameters of LRU cache for some features like secret lru: diff --git a/t/APISIX.pm b/t/APISIX.pm index 3d0520cc2f95..05d477cbf2e8 100644 --- a/t/APISIX.pm +++ b/t/APISIX.pm @@ -963,7 +963,7 @@ _EOC_ 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_x_forwarded_for ''; set \$original_forwarded \$http_forwarded; more_set_input_headers "X-Forwarded-Proto: \$scheme"; more_set_input_headers "X-Forwarded-Host: \$var_x_forwarded_host"; diff --git a/t/core/trusted-addresses.t b/t/core/trusted-addresses.t index 8426628e904b..6833613925f9 100644 --- a/t/core/trusted-addresses.t +++ b/t/core/trusted-addresses.t @@ -628,6 +628,7 @@ GET /old_uri X-Forwarded-For: 9.9.9.9 X-Forwarded-Proto: https X-Forwarded-Host: evil.com +X-Forwarded-Port: 8443 Forwarded: for=1.2.3.4 --- response_body uri: /old_uri @@ -701,7 +702,7 @@ routes: serverless-pre-function: phase: access functions: - - "return function(conf, ctx) ngx.log(ngx.WARN, \"orig xff: \", tostring(ctx.var.original_x_forwarded_for)) end" + - "return function(conf, ctx) ngx.log(ngx.WARN, \"orig xff: \", tostring(ctx.var.original_x_forwarded_for), \" current: \", tostring(ctx.var.http_x_forwarded_for)) end" upstream: nodes: "127.0.0.1:1980": 1 @@ -720,4 +721,35 @@ x-forwarded-port: 1984 x-forwarded-proto: http x-real-ip: 127.0.0.1 --- error_log -orig xff: 9.9.9.9, 8.8.8.8 +orig xff: 9.9.9.9, 8.8.8.8 current: nil + + + +=== TEST 18: a route matching on http_x_forwarded_for does not see the cleared chain +--- yaml_config +apisix: + node_listen: 1984 + enable_admin: false + trusted_addresses: + - "10.0.0.0/8" +deployment: + role: data_plane + role_data_plane: + config_provider: yaml +--- apisix_yaml +routes: + - + id: 1 + uri: /old_uri + vars: + - ["http_x_forwarded_for", "==", "9.9.9.9"] + upstream: + nodes: + "127.0.0.1:1980": 1 + type: roundrobin +#END +--- request +GET /old_uri +--- more_headers +X-Forwarded-For: 9.9.9.9 +--- error_code: 404