Skip to content

Commit 2b69dbc

Browse files
authored
perf(core): sanitize the X-Forwarded-* headers in the NGINX config (#13803)
1 parent 32a59c8 commit 2b69dbc

8 files changed

Lines changed: 611 additions & 106 deletions

File tree

apisix/cli/ngx_tpl.lua

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,28 @@ stream {
273273
274274
{% if enable_http then %}
275275
http {
276+
# X-Forwarded-* sanitization, first half. The second is
277+
# `handle_trusted_x_forwarded_headers` in apisix/init.lua.
278+
#
279+
# Every request is neutralized unconditionally, in the rewrite phase, in C.
280+
# That is the case worth optimizing for: with no `apisix.trusted_addresses`
281+
# configured no peer is trusted, so it is what every request gets.
282+
#
283+
# These keep the names the `set` directives they replace used, and hold the
284+
# same thing: what X-Forwarded-Host and X-Forwarded-Port are given below.
285+
#
286+
# the port carried by the Host header, falling back to the listener's own
287+
map $http_host $var_x_forwarded_port {
288+
default $server_port;
289+
"~:(?<p>\\d+)$" $p;
290+
}
291+
# `$http_host` rather than `$host`: the port the client connected to belongs
292+
# in X-Forwarded-Host, and `$host` drops it
293+
map $http_host $var_x_forwarded_host {
294+
default $http_host;
295+
"" $host;
296+
}
297+
276298
# put extra_lua_path in front of the builtin path
277299
# so user can override the source code
278300
lua_package_path "{*extra_lua_path*}$prefix/deps/share/lua/5.1/?.lua;$prefix/deps/share/lua/5.1/?/init.lua;]=]
@@ -880,14 +902,43 @@ http {
880902
881903
### the following x-forwarded-* headers is to send to upstream server
882904
883-
set $var_x_forwarded_proto $scheme;
884-
set $var_x_forwarded_host $host;
885-
set $var_x_forwarded_port $server_port;
886-
905+
# Take copies before neutralizing, so a trusted peer's own values can
906+
# be put back. ngx_rewrite's `set` runs before headers_more's handler,
907+
# which is what makes this ordering work -- do not reorder these.
908+
#
909+
# Reading `$http_x_forwarded_*` here indexes them, so they keep the
910+
# client's raw value for the rest of the request. Nothing downstream
911+
# derives from them -- the upstream headers come from `r->headers_in`
912+
# and Lua's `ctx.var.http_x_forwarded_*` re-reads it through the prefix
913+
# handler -- but an access log format that names them logs what the
914+
# client sent. `$scheme` / `$var_x_forwarded_host` /
915+
# `$var_x_forwarded_port` are the sanitized values.
916+
set $original_x_forwarded_proto $http_x_forwarded_proto;
917+
set $original_x_forwarded_host $http_x_forwarded_host;
918+
set $original_x_forwarded_port $http_x_forwarded_port;
919+
# X-Forwarded-For is the one that cannot be copied here. Unlike
920+
# `$http_x_forwarded_proto` and friends, which are prefix variables and
921+
# are re-evaluated on every read, `$http_x_forwarded_for` is a dedicated
922+
# entry in `ngx_http_core_variables[]`; naming it in the configuration
923+
# makes it indexed, and this `set` would then pin the client's value in
924+
# `r->variables[]` for the whole request -- surviving the clear below and
925+
# feeding it back to route `vars`, rate-limit keys and every other
926+
# `ctx.var` reader. Lua fills the slot instead, in the one branch that
927+
# destroys the value.
928+
set $original_x_forwarded_for '';
929+
set $original_forwarded $http_forwarded;
930+
more_set_input_headers "X-Forwarded-Proto: $scheme";
931+
more_set_input_headers "X-Forwarded-Host: $var_x_forwarded_host";
932+
more_set_input_headers "X-Forwarded-Port: $var_x_forwarded_port";
933+
more_set_input_headers "Forwarded: ";
934+
935+
# X-Forwarded-Proto/Host/Port are not set here: `r->headers_in` already
936+
# holds the values this request should carry, and proxy_pass forwards it
937+
# as it stands. That is also what lets a plugin rewrite them -- a
938+
# `proxy_set_header` would overwrite the plugin's value with whatever the
939+
# variable held. X-Forwarded-For is different: the connection address has
940+
# to be appended, which only $proxy_add_x_forwarded_for does.
887941
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
888-
proxy_set_header X-Forwarded-Proto $var_x_forwarded_proto;
889-
proxy_set_header X-Forwarded-Host $var_x_forwarded_host;
890-
proxy_set_header X-Forwarded-Port $var_x_forwarded_port;
891942
892943
{% if enabled_plugins["proxy-cache"] or enabled_plugins["graphql-proxy-cache"] then %}
893944
### the following configuration is to cache response content from upstream server
@@ -1000,10 +1051,13 @@ http {
10001051
proxy_set_header X-Real-IP $remote_addr;
10011052
proxy_pass_header Date;
10021053
1054+
# X-Forwarded-Proto/Host/Port are not set here: `r->headers_in` already
1055+
# holds the values this request should carry, and proxy_pass forwards it
1056+
# as it stands. That is also what lets a plugin rewrite them -- a
1057+
# `proxy_set_header` would overwrite the plugin's value with whatever the
1058+
# variable held. X-Forwarded-For is different: the connection address has
1059+
# to be appended, which only $proxy_add_x_forwarded_for does.
10031060
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
1004-
proxy_set_header X-Forwarded-Proto $var_x_forwarded_proto;
1005-
proxy_set_header X-Forwarded-Host $var_x_forwarded_host;
1006-
proxy_set_header X-Forwarded-Port $var_x_forwarded_port;
10071061
10081062
proxy_pass $upstream_scheme://apisix_backend$upstream_uri;
10091063

apisix/core/ctx.lua

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -249,9 +249,8 @@ do
249249

250250
rate_limiting_info = true,
251251

252-
var_x_forwarded_proto = true,
253-
var_x_forwarded_port = true,
254-
var_x_forwarded_host = true,
252+
original_x_forwarded_for = true,
253+
255254
}
256255

257256
-- sort in alphabetical

apisix/init.lua

Lines changed: 60 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -686,88 +686,73 @@ function _M.handle_upstream(api_ctx, route, enable_websocket)
686686
end
687687

688688

689-
local function handle_x_forwarded_headers(api_ctx)
690-
local addr_is_trusted = trusted_addresses_util.is_trusted(api_ctx.var.realip_remote_addr)
691-
692-
-- Only untrusted values need to be overwritten or cleared.
693-
if not addr_is_trusted then
694-
-- store the original x-forwarded-* headers
695-
-- to allow future use by other plugins or processes
696-
api_ctx.var.original_x_forwarded_proto = api_ctx.var.http_x_forwarded_proto
697-
api_ctx.var.original_x_forwarded_host = api_ctx.var.http_x_forwarded_host
698-
api_ctx.var.original_x_forwarded_port = api_ctx.var.http_x_forwarded_port
699-
api_ctx.var.original_x_forwarded_for = api_ctx.var.http_x_forwarded_for
700-
701-
-- trusted ones
702-
-- ref: ngx_tpl.lua#L831-L840
703-
--
704-
-- these values are observed directly by APISIX and cannot be forged,
705-
-- making them highly credible.
706-
local proto = api_ctx.var.scheme
707-
local http_host = api_ctx.var.http_host or api_ctx.var.host
708-
-- parse_addr handles IPv6 literals and bracketed host:port correctly.
709-
local _, port_from_host = core.utils.parse_addr(http_host)
710-
local host = http_host
711-
local port = port_from_host or api_ctx.var.server_port
712-
713-
-- override the x-forwarded-* headers to the trusted ones.
714-
-- make sure that the correct values ​​are obtained
715-
-- in the subsequent stages using `core.request.header`.
716-
core.request.set_header(api_ctx, "X-Forwarded-Proto", proto)
717-
core.request.set_header(api_ctx, "X-Forwarded-Host", host)
718-
core.request.set_header(api_ctx, "X-Forwarded-Port", port)
719-
-- Clear RFC 7239 Forwarded header to prevent forgery.
720-
core.request.set_header(api_ctx, "Forwarded", nil)
721-
722-
-- X-Forwarded-For: when a trust boundary is configured but this peer is
723-
-- untrusted, reset it so the upstream only sees the APISIX-observed
724-
-- connection IP via `$proxy_add_x_forwarded_for`, dropping the spoofable
725-
-- inbound chain. When `trusted_addresses` is unset, keep the compatible
726-
-- default of preserving the inbound chain (the connection IP is appended).
727-
if trusted_addresses_util.is_configured() then
728-
core.request.set_header(api_ctx, "X-Forwarded-For", nil)
729-
api_ctx.var.http_x_forwarded_for = nil
730-
end
731-
732-
-- update the cached value in http_x_forwarded_* to the trusted ones.
733-
-- make sure that the correct values ​​are obtained
734-
-- in the subsequent stages using `var.http_x_forwarded_*`.
735-
api_ctx.var.http_x_forwarded_proto = proto
736-
api_ctx.var.http_x_forwarded_host = host
737-
api_ctx.var.http_x_forwarded_port = port
738-
api_ctx.var.http_forwarded = nil
689+
-- X-Forwarded-Proto/Host/Port and Forwarded are already neutralized by the time
690+
-- this runs: `more_set_input_headers` in apisix/cli/ngx_tpl.lua does it in the
691+
-- rewrite phase, in C, on every request. That is unconditional because with no
692+
-- trust boundary configured -- the default -- it is what every request needs, and
693+
-- keeping it in the config keeps Lua off that path entirely.
694+
--
695+
-- What is left needs a trust decision, so it stays here, behind a check that is a
696+
-- constant for the worker's lifetime: with no `trusted_addresses` this returns on
697+
-- its first line and nothing else runs.
698+
--
699+
-- `set` captures an absent header as the empty string, so "" means the peer sent
700+
-- nothing and the value the config injected stays. That is a deliberate change
701+
-- for a trusted peer: the Lua-only implementation skipped the whole rewrite for
702+
-- one, so a header it did not send stayed absent and the upstream fell through to
703+
-- `$host` / `$server_port`. A trusted peer now gets the same observed values an
704+
-- untrusted one does -- the Host with its port and case, rather than the
705+
-- lower-cased portless `$host` -- which is the value the untrusted path has always
706+
-- produced. `ctx.var.http_x_forwarded_*` is updated alongside, so a plugin reading
707+
-- it in a later phase sees the restored value rather than the injected one.
708+
local function restore_if_sent(api_ctx, header_name, var_name, orig)
709+
if not orig or orig == "" then
710+
return
739711
end
712+
713+
core.request.set_header(api_ctx, header_name, orig)
714+
api_ctx.var[var_name] = orig
740715
end
741716

742717

743-
-- in ngx_tpl.lua#L831-L840,
744-
-- there is such code: `proxy_set_header X-Forwarded-XXX $var_x_forwarded_xxx;`
745-
-- that is, set the `X-Forwarded-XXX` header through `var_x_forwarded_xxx`.
746-
--
747-
-- therefore, it is necessary to set the trusted `http_x_forwarded_xxx` to `var_x_forwarded_xxx`.
748-
-- So that the `X-Forwarded-XXX` header is updated to a trusted value.
749-
--
750-
-- currently, only following headers are updated through these variables:
751-
-- - X-Forwarded-Proto
752-
-- - X-Forwarded-Port
753-
-- - X-Forwarded-Host
754-
--
755-
-- the `X-Forwarded-For` header is not updated through these variables.
756-
-- because it is set by the `proxy_add_x_forwarded_for` directive.
757-
local function set_upstream_x_forwarded_headers(api_ctx)
758-
local proto = api_ctx.var.http_x_forwarded_proto
759-
if proto then
760-
api_ctx.var.var_x_forwarded_proto = proto
718+
local function handle_trusted_x_forwarded_headers(api_ctx)
719+
-- The other four originals are copied by the configuration; this one cannot be,
720+
-- because naming `$http_x_forwarded_for` there would pin it in `r->variables[]`
721+
-- and the clear below could not dislodge it. Copy it here instead, on every
722+
-- path: the header is only destroyed further down, but a plugin reading
723+
-- `ctx.var.original_x_forwarded_for` should not have to know that.
724+
local inbound_xff = api_ctx.var.http_x_forwarded_for
725+
if inbound_xff then
726+
api_ctx.var.original_x_forwarded_for = inbound_xff
761727
end
762728

763-
local port = api_ctx.var.http_x_forwarded_port
764-
if port then
765-
api_ctx.var.var_x_forwarded_port = port
729+
if not trusted_addresses_util.is_configured() then
730+
return
766731
end
767732

768-
local host = api_ctx.var.http_x_forwarded_host
769-
if host then
770-
api_ctx.var.var_x_forwarded_host = host
733+
if trusted_addresses_util.is_trusted(api_ctx.var.realip_remote_addr) then
734+
-- a trusted peer's own values go back, from the copies the config took
735+
-- before overwriting them
736+
restore_if_sent(api_ctx, "X-Forwarded-Proto", "http_x_forwarded_proto",
737+
api_ctx.var.original_x_forwarded_proto)
738+
restore_if_sent(api_ctx, "X-Forwarded-Host", "http_x_forwarded_host",
739+
api_ctx.var.original_x_forwarded_host)
740+
restore_if_sent(api_ctx, "X-Forwarded-Port", "http_x_forwarded_port",
741+
api_ctx.var.original_x_forwarded_port)
742+
restore_if_sent(api_ctx, "Forwarded", "http_forwarded",
743+
api_ctx.var.original_forwarded)
744+
745+
return
746+
end
747+
748+
-- An untrusted peer, with a trust boundary to measure it against: drop the
749+
-- inbound X-Forwarded-For so the upstream only sees the connection IP via
750+
-- `$proxy_add_x_forwarded_for`. Without a boundary the chain is preserved,
751+
-- which is the compatible default and is why this lives behind the check
752+
-- above rather than in the config.
753+
if inbound_xff then
754+
core.request.set_header(api_ctx, "X-Forwarded-For", nil)
755+
api_ctx.var.http_x_forwarded_for = nil
771756
end
772757
end
773758

@@ -828,7 +813,7 @@ function _M.http_access_phase()
828813
-- var.request is read-only; copy to a writable variable so data-mask can redact query params
829814
api_ctx.var.request_line = api_ctx.var.request
830815

831-
handle_x_forwarded_headers(api_ctx)
816+
handle_trusted_x_forwarded_headers(api_ctx)
832817

833818
-- When match_uri_encoded_slash is on, match the route against a uri that
834819
-- keeps the encoded slash (%2F) so it is treated as part of a path
@@ -970,10 +955,6 @@ function _M.http_access_phase()
970955
end
971956
span:finish(ngx_ctx)
972957

973-
-- set before handle_upstream: grpc/dubbo/disable_proxy_buffering exit via
974-
-- ngx.exec() and never return, so the trusted values must be applied first.
975-
set_upstream_x_forwarded_headers(api_ctx)
976-
977958
_M.handle_upstream(api_ctx, route, enable_websocket)
978959
end
979960

conf/config.yaml.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,23 @@ apisix:
168168
# address, it is reset so the upstream only sees the APISIX-observed
169169
# connection IP; when `trusted_addresses` is not configured, it is
170170
# preserved and the connection IP is appended (compatible default).
171+
# The override happens in the NGINX configuration, before any Lua runs.
172+
# Lua always sees the overridden values -- `core.request.header` and
173+
# `ctx.var.http_x_forwarded_*` alike. At config level it differs:
174+
# naming `$http_x_forwarded_proto`, `$http_x_forwarded_host`,
175+
# `$http_x_forwarded_port` or `$http_forwarded` in an access log
176+
# format, an `if`, or a `map` reads the value cached when the
177+
# override was applied, which is what the client sent. Use `$scheme`,
178+
# `$var_x_forwarded_host` and `$var_x_forwarded_port` for the
179+
# overridden ones. `$http_x_forwarded_for` is not affected.
180+
# What the client sent is kept in `$original_x_forwarded_proto`,
181+
# `$original_x_forwarded_host`, `$original_x_forwarded_port`,
182+
# `$original_x_forwarded_for` and `$original_forwarded` -- readable
183+
# from a log format, or from Lua as `ctx.var.original_x_forwarded_*`,
184+
# the same names the values were previously kept under, so a plugin
185+
# reading them is unaffected. This matters most for X-Forwarded-For,
186+
# which is cleared rather than overwritten when a trust boundary is
187+
# configured and the peer is outside it.
171188
# fine tune the parameters of LRU cache for some features like secret
172189
lru:
173190
secret:

t/APISIX.pm

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,6 @@ my $disable_proxy_buffering_location = <<_EOC_;
238238
proxy_pass_header Date;
239239
240240
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
241-
proxy_set_header X-Forwarded-Proto \$var_x_forwarded_proto;
242-
proxy_set_header X-Forwarded-Host \$var_x_forwarded_host;
243-
proxy_set_header X-Forwarded-Port \$var_x_forwarded_port;
244241
245242
proxy_pass \$upstream_scheme://apisix_backend\$upstream_uri;
246243
mirror /proxy_mirror;
@@ -626,6 +623,16 @@ _EOC_
626623
$http_config .= <<_EOC_;
627624
$lua_deps_path
628625
626+
# mirrors apisix/cli/ngx_tpl.lua
627+
map \$http_host \$var_x_forwarded_port {
628+
default \$server_port;
629+
"~:(?<p>\\\\d+)\$" \$p;
630+
}
631+
map \$http_host \$var_x_forwarded_host {
632+
default \$http_host;
633+
"" \$host;
634+
}
635+
629636
lua_shared_dict plugin-limit-req 10m;
630637
lua_shared_dict plugin-limit-count 10m;
631638
lua_shared_dict plugin-limit-count-lock 10m;
@@ -953,16 +960,19 @@ _EOC_
953960
proxy_set_header X-Real-IP \$remote_addr;
954961
proxy_pass_header Date;
955962
956-
### the following x-forwarded-* headers is to send to upstream server
963+
set \$original_x_forwarded_proto \$http_x_forwarded_proto;
964+
set \$original_x_forwarded_host \$http_x_forwarded_host;
965+
set \$original_x_forwarded_port \$http_x_forwarded_port;
966+
set \$original_x_forwarded_for '';
967+
set \$original_forwarded \$http_forwarded;
968+
more_set_input_headers "X-Forwarded-Proto: \$scheme";
969+
more_set_input_headers "X-Forwarded-Host: \$var_x_forwarded_host";
970+
more_set_input_headers "X-Forwarded-Port: \$var_x_forwarded_port";
971+
more_set_input_headers "Forwarded: ";
957972
958-
set \$var_x_forwarded_proto \$scheme;
959-
set \$var_x_forwarded_host \$host;
960-
set \$var_x_forwarded_port \$server_port;
973+
### the following x-forwarded-* headers is to send to upstream server
961974
962975
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
963-
proxy_set_header X-Forwarded-Proto \$var_x_forwarded_proto;
964-
proxy_set_header X-Forwarded-Host \$var_x_forwarded_host;
965-
proxy_set_header X-Forwarded-Port \$var_x_forwarded_port;
966976
967977
proxy_pass \$upstream_scheme://apisix_backend\$upstream_uri;
968978
mirror /proxy_mirror;

0 commit comments

Comments
 (0)