From e65a0f6087bf0b794c0768bdbe71fcbbd807cbe9 Mon Sep 17 00:00:00 2001 From: sneak Date: Wed, 11 Nov 2026 06:58:51 +0000 Subject: [PATCH] rproxy: remove forwarded headers spelt with underscores from an untrusted peer (closes #334) For a peer outside `SWRP_TRUSTED_PROXIES`, `rproxy` now reads each header name with `_` as `-` and in any letter case, and removes it when it starts with `X-Forwarded-` or is `Forwarded` or `X-Real-IP`. An app behind CGI or FastCGI reads `X_Forwarded_Ssl` as `HTTP_X_FORWARDED_SSL`, the same as `X-Forwarded-Ssl`, so a client connecting directly could still make it take a plain request for a secure one. A trusted peer's headers pass on unchanged. `SPEC.md` says so. Model: opus-5-5 --- SPEC.md | 3 ++- internal/rproxy/client.go | 11 +++++------ internal/rproxy/rproxy_test.go | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/SPEC.md b/SPEC.md index c7c6b57..2a86eb1 100644 --- a/SPEC.md +++ b/SPEC.md @@ -2326,7 +2326,8 @@ but for these headers: claims and are replaced. `X-Forwarded-For` names the peer, `X-Forwarded-Host` the host it asked for, and `X-Forwarded-Proto` plain `http`. Every other header whose name starts with `X-Forwarded-` is removed, and so are - `Forwarded` and `X-Real-IP`. + `Forwarded` and `X-Real-IP`. A name spelt with `_` for `-`, such as + `X_Forwarded_Ssl`, counts as the same name. - `X-Request-ID`, which carries the fresh id. - `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 diff --git a/internal/rproxy/client.go b/internal/rproxy/client.go index c7216bb..be5b321 100644 --- a/internal/rproxy/client.go +++ b/internal/rproxy/client.go @@ -105,17 +105,16 @@ func setForwardedHeaders(in, out *http.Request, peer netip.Addr, trusted bool) { return } - // ReverseProxy has removed Forwarded and the three set below. Go's server - // gives header names in canonical form, so the prefix matches the name in - // any letter case. + // An app behind CGI or FastCGI reads X_Forwarded_Ssl as X-Forwarded-Ssl, so + // a name is matched with each _ read as - and in any letter case. for name := range out.Header { - if strings.HasPrefix(name, "X-Forwarded-") { + spelt := strings.ToLower(strings.ReplaceAll(name, "_", "-")) + if strings.HasPrefix(spelt, "x-forwarded-") || + spelt == "forwarded" || spelt == "x-real-ip" { delete(out.Header, name) } } - out.Header.Del("X-Real-IP") - out.Header.Set("X-Forwarded-For", forwardedFor) out.Header.Set("X-Forwarded-Host", in.Host) out.Header.Set("X-Forwarded-Proto", "http") diff --git a/internal/rproxy/rproxy_test.go b/internal/rproxy/rproxy_test.go index ee1a452..fb39dac 100644 --- a/internal/rproxy/rproxy_test.go +++ b/internal/rproxy/rproxy_test.go @@ -561,6 +561,7 @@ func TestAPeerNotTrustedHasItsForwardedHeadersReplaced(t *testing.T) { "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", + "X_Forwarded_Ssl", "X_Forwarded_Proto", "X_Real_IP", } standIn := startWAF(t, allow) @@ -628,6 +629,7 @@ func TestATrustedProxysForwardedHeadersPassOn(t *testing.T) { forwardedProtoHeader: "https", "X-Real-Ip": "198.51.100.1", "X-Forwarded-Ssl": "on", + "X_Forwarded_Ssl": "on", } req := newRequest(t, http.MethodGet, addr, "/", http.NoBody)