rproxy: remove every X-Forwarded- header from an untrusted peer (closes #325)
check / check (push) Waiting to run

For a peer outside `SWRP_TRUSTED_PROXIES`, `rproxy` now removes every header whose name starts with `X-Forwarded-`, in any letter case, before it sets `X-Forwarded-For`, `X-Forwarded-Host` and `X-Forwarded-Proto`; `Forwarded` and `X-Real-IP` are removed as before. Until now only traefik's names were removed, so a client connecting directly could send `X-Forwarded-Ssl: on` or `X-Forwarded-Scheme: https` and make an app take a plain request for a secure one. A trusted peer's headers pass on unchanged. `SPEC.md` says so.

Model: opus-5-5
This commit was merged in pull request #330.
This commit is contained in:
clawbot committed 2026-11-11 07:50:50 +01:00
1 parent f76308add3
commit 7b52574e5b
3 files changed
+19 -12

No files matched your search

+3 -2
View File
@@ -2324,8 +2324,9 @@ but for these headers:
on. on.
- For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own - For a peer outside `SWRP_TRUSTED_PROXIES`, its forwarded headers are its own
claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host` claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host`
the host it asked for, and `X-Forwarded-Proto` plain `http`. The other headers the host it asked for, and `X-Forwarded-Proto` plain `http`. Every other
in which traefik tells an app about a visitor are removed. header whose name starts with `X-Forwarded-` is removed, and so are
`Forwarded` and `X-Real-IP`.
- `X-Request-ID`, which carries the fresh id. - `X-Request-ID`, which carries the fresh id.
- `X-Client-ASN` and `X-Client-Country`, which carry what `waf` answered. A - `X-Client-ASN` and `X-Client-Country`, which carry what `waf` answered. A
header it did not give is left out. Any of the two the visitor sent is header it did not give is left out. Any of the two the visitor sent is
+9 -9
View File
@@ -105,17 +105,17 @@ func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) {
return return
} }
// ReverseProxy has removed Forwarded and the three set below; these are the // ReverseProxy has removed Forwarded and the three set below. Go's server
// other headers in which traefik tells the app about the visitor and its // gives header names in canonical form, so the prefix matches the name in
// request. // any letter case.
for _, name := range []string{ for name := range out.Header {
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri", if strings.HasPrefix(name, "X-Forwarded-") {
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert", delete(out.Header, name)
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", }
} {
out.Header.Del(name)
} }
out.Header.Del("X-Real-IP")
out.Header.Set("X-Forwarded-For", forwardedFor) out.Header.Set("X-Forwarded-For", forwardedFor)
out.Header.Set("X-Forwarded-Host", in.Host) out.Header.Set("X-Forwarded-Host", in.Host)
out.Header.Set("X-Forwarded-Proto", "http") out.Header.Set("X-Forwarded-Proto", "http")
+7 -1
View File
@@ -559,6 +559,8 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
"X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri", "X-Forwarded-Port", "X-Forwarded-Server", "X-Forwarded-Uri",
"X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert", "X-Forwarded-Method", "X-Forwarded-Prefix", "X-Forwarded-Tls-Client-Cert",
"X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip", "X-Forwarded-Tls-Client-Cert-Info", "X-Real-Ip",
"X-Forwarded-Scheme", "X-Forwarded-Ssl", "X-Forwarded-Protocol",
"X-Forwarded-Path", "X-Forwarded-Client-Cert",
} }
standIn := startWAF(t, allow) standIn := startWAF(t, allow)
@@ -582,6 +584,9 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
req.Header.Set(name, "claimed") req.Header.Set(name, "claimed")
} }
// A made-up name, sent in lower case.
req.Header["x-forwarded-made-up"] = []string{"claimed"}
wantGreeting(t, visit(t, req)) wantGreeting(t, visit(t, req))
header := <-seen header := <-seen
@@ -596,7 +601,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) {
} }
} }
for _, name := range removed { for _, name := range append(removed, "X-Forwarded-Made-Up") {
if got := header.Values(name); len(got) != 0 { if got := header.Values(name); len(got) != 0 {
t.Errorf("the app saw %s %q, want none", name, got) t.Errorf("the app saw %s %q, want none", name, got)
} }
@@ -622,6 +627,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) {
"X-Forwarded-Host": "public.example", "X-Forwarded-Host": "public.example",
forwardedProtoHeader: "https", forwardedProtoHeader: "https",
"X-Real-Ip": "198.51.100.1", "X-Real-Ip": "198.51.100.1",
"X-Forwarded-Ssl": "on",
} }
req := newRequest(t, http.MethodGet, addr, "/", http.NoBody) req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)