From a429456987395027e8a67d2582d1249283d6b96c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Asger=20Weirs=C3=B8e?= Date: Sat, 30 May 2026 21:23:01 +0200 Subject: [PATCH] fetch: strip all origin/chain headers so nothing leaks to targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fetch service only stripped the four X-Weircon-* control headers, so any forwarding header injected upstream (X-Forwarded-For, X-Real-IP, Via, CDN client-IP headers, …) passed straight through to the target — leaking the caller's IP and proxy chain. - Replace stripWeircon with stripIdentifying: removes the control headers plus all standard forwarding/origin-IP headers, with a prefix sweep for any vendor-specific X-Forwarded-* variant. - NPM advanced.conf clears the same headers (defense in depth). - Add TestStripIdentifying covering removal + survival of legit headers. Co-Authored-By: Claude Opus 4.8 (1M context) --- npm/advanced.conf | 12 +++++++++ service/main.go | 42 +++++++++++++++++++++++++++++--- service/main_test.go | 58 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 service/main_test.go diff --git a/npm/advanced.conf b/npm/advanced.conf index c806355..9b30291 100644 --- a/npm/advanced.conf +++ b/npm/advanced.conf @@ -21,6 +21,18 @@ if ($weircon_auth_ok = 0) { # 3. Strip the auth header before forwarding — backend should never see it. proxy_set_header X-Weircon-Random-Ip ""; +# 3b. Defense in depth: never forward origin/chain headers. The fetch service +# also strips these, but clearing them here means they never even reach it. +# In nginx, an empty value removes the header entirely. +proxy_set_header X-Forwarded-For ""; +proxy_set_header X-Forwarded-Host ""; +proxy_set_header X-Forwarded-Proto ""; +proxy_set_header X-Forwarded-Scheme ""; +proxy_set_header X-Forwarded-Port ""; +proxy_set_header X-Real-IP ""; +proxy_set_header Forwarded ""; +proxy_set_header Via ""; + # 4. Generous timeouts: upstream fetches can be slow. proxy_connect_timeout 15s; proxy_send_timeout 60s; diff --git a/service/main.go b/service/main.go index 048049a..4cb15c0 100644 --- a/service/main.go +++ b/service/main.go @@ -145,17 +145,51 @@ func (p *poolTransport) RoundTrip(req *http.Request) (*http.Response, error) { return p.pool[rc.proxyID].RoundTrip(req) } -var weirconHeaders = []string{ +// stripHeaders are removed from the outbound request so nothing about the +// caller or the proxy chain can reach the target. It covers our own control +// headers plus every standard forwarding / client-IP header that a fronting +// reverse proxy (NPM, nginx, Cloudflare, …) might inject. Any other +// "X-Forwarded-*" header is swept by prefix in stripIdentifying. +var stripHeaders = []string{ + // weircon control headers "X-Weircon-Random-Ip", "X-Weircon-Random-Ip-Redirect", "X-Weircon-Proxy-Id", "X-Weircon-Forward-Method", + // generic forwarding / origin-IP headers + "Forwarded", + "Via", + "X-Real-Ip", + "X-Original-Forwarded-For", + "X-Client-Ip", + "X-Cluster-Client-Ip", + "X-Original-Url", + "X-Original-Host", + "X-Rewrite-Url", + "X-Proxy-Id", + // CDN / vendor client-IP headers + "Cf-Connecting-Ip", + "Cf-Ipcountry", + "Cf-Ray", + "True-Client-Ip", + "Fastly-Client-Ip", + "Fly-Client-Ip", + "X-Appengine-User-Ip", } -func stripWeircon(h http.Header) { - for _, k := range weirconHeaders { +// stripIdentifying removes every header that could reveal the caller or the +// proxy chain. The explicit list catches the common ones; the prefix sweep +// catches any vendor-specific X-Forwarded-* we didn't enumerate. Header keys +// in an http.Header are already canonicalized, so prefix matching is exact. +func stripIdentifying(h http.Header) { + for _, k := range stripHeaders { h.Del(k) } + for k := range h { + if strings.HasPrefix(k, "X-Forwarded") { + h.Del(k) + } + } } func pickProxyID(req *http.Request, n int) (int, error) { @@ -212,7 +246,7 @@ func newHandler(cfg Config, pool []*http.Transport) http.Handler { pr.Out.Host = rc.target.Host pr.Out.Method = rc.method pr.Out.RequestURI = "" - stripWeircon(pr.Out.Header) + stripIdentifying(pr.Out.Header) }, Transport: &poolTransport{pool: pool}, ModifyResponse: func(resp *http.Response) error { diff --git a/service/main_test.go b/service/main_test.go new file mode 100644 index 0000000..7b87cce --- /dev/null +++ b/service/main_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "net/http" + "testing" +) + +func TestStripIdentifying(t *testing.T) { + h := http.Header{} + + // Headers that MUST be removed before the request reaches the target. + removed := []string{ + "X-Weircon-Random-Ip", + "X-Weircon-Proxy-Id", + "X-Forwarded-For", + "X-Forwarded-Host", + "X-Forwarded-Proto", + "X-Forwarded-Port", + "X-Forwarded-Custom-Vendor", // caught by prefix sweep + "X-Real-Ip", + "Forwarded", + "Via", + "Cf-Connecting-Ip", + "True-Client-Ip", + "Fastly-Client-Ip", + "X-Original-Forwarded-For", + "X-Client-Ip", + } + for _, k := range removed { + h.Set(k, "leak") + } + + // Headers a crawler legitimately sets — these MUST survive untouched. + kept := map[string]string{ + "User-Agent": "my-crawler/1.0", + "Cookie": "session=abc", + "Accept": "text/html", + "Accept-Language": "en-US", + "Referer": "https://example.com", + "X-Forward": "not-a-forwarding-header", // does not match "X-Forwarded" prefix + } + for k, v := range kept { + h.Set(k, v) + } + + stripIdentifying(h) + + for _, k := range removed { + if got := h.Get(k); got != "" { + t.Errorf("expected %q to be stripped, still present: %q", k, got) + } + } + for k, want := range kept { + if got := h.Get(k); got != want { + t.Errorf("expected %q to survive as %q, got %q", k, want, got) + } + } +}