From ff7af5854626cf563d74f3014ed614718480aee2 Mon Sep 17 00:00:00 2001 From: Team Humaki Date: Tue, 15 Sep 2026 15:09:28 -0700 Subject: [PATCH] csrf: honor Sec-Fetch-Site same-origin when Host is rewritten --- csrf.go | 4 +-- csrf_test.go | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++++ helpers.go | 8 +++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/csrf.go b/csrf.go index 5dda254..58a6cab 100644 --- a/csrf.go +++ b/csrf.go @@ -285,7 +285,7 @@ func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request) { cs.opts.ErrorHandler.ServeHTTP(w, r) return } - if !sameOrigin(&requestURL, parsedOrigin) && !slices.Contains(cs.opts.TrustedOrigins, parsedOrigin.Host) { + if !sameOrigin(&requestURL, parsedOrigin) && !slices.Contains(cs.opts.TrustedOrigins, parsedOrigin.Host) && !sameOriginFetch(r) { r = envError(r, ErrBadOrigin) cs.opts.ErrorHandler.ServeHTTP(w, r) return @@ -318,7 +318,7 @@ func (cs *csrf) ServeHTTP(w http.ResponseWriter, r *http.Request) { // If the request is being served via TLS and the Referer is not the // same origin, check the domain against our allowlist. We only // check when we have host information from the referer. - if referer.Host != "" && referer.Host != r.Host && !slices.Contains(cs.opts.TrustedOrigins, referer.Host) { + if referer.Host != "" && referer.Host != r.Host && !slices.Contains(cs.opts.TrustedOrigins, referer.Host) && !sameOriginFetch(r) { r = envError(r, ErrBadReferer) cs.opts.ErrorHandler.ServeHTTP(w, r) return diff --git a/csrf_test.go b/csrf_test.go index 0281680..5a8f7dd 100644 --- a/csrf_test.go +++ b/csrf_test.go @@ -542,3 +542,85 @@ func createRequest(method, path string, useTLS bool) *http.Request { } return r } + +// TestReverseProxyRewrittenHost covers gorilla/csrf#187: a same-origin +// browser POST whose Host was rewritten by a reverse proxy must still +// pass when Sec-Fetch-Site is same-origin. Cross-site and missing +// Sec-Fetch-Site must not. +func TestReverseProxyRewrittenHost(t *testing.T) { + tests := []struct { + name string + origin string + referer string + secFetchSite string + want bool + }{ + { + name: "Origin mismatch with Sec-Fetch-Site same-origin pass", + origin: "https://www.gorillatoolkit.org", + secFetchSite: "same-origin", + want: true, + }, + { + name: "Origin mismatch without Sec-Fetch-Site reject", + origin: "https://www.gorillatoolkit.org", + }, + { + name: "Origin mismatch with Sec-Fetch-Site cross-site reject", + origin: "https://www.gorillatoolkit.org", + secFetchSite: "cross-site", + }, + { + name: "Referer mismatch with Sec-Fetch-Site same-origin pass", + referer: "https://www.gorillatoolkit.org/form", + secFetchSite: "same-origin", + want: true, + }, + { + name: "Referer mismatch without Sec-Fetch-Site reject", + referer: "https://www.gorillatoolkit.org/form", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var token string + mux := http.NewServeMux() + mux.Handle("/", http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) { + token = Token(r) + })) + mux.Handle("/submit", http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + p := Protect(testKey)(mux) + + r := createRequest("GET", "/", true) + rr := httptest.NewRecorder() + p.ServeHTTP(rr, r) + + r = createRequest("POST", "/submit", true) + r.Host = "backend.acme.example" + if tt.origin != "" { + r.Header.Set("Origin", tt.origin) + } + if tt.referer != "" { + r.Header.Set("Referer", tt.referer) + } + if tt.secFetchSite != "" { + r.Header.Set("Sec-Fetch-Site", tt.secFetchSite) + } + setCookie(rr, r) + r.Header.Set("X-CSRF-Token", token) + + rr = httptest.NewRecorder() + p.ServeHTTP(rr, r) + + if tt.want && rr.Code != http.StatusOK { + t.Fatalf("middleware failed to pass rewritten-Host request: got %v want %v", + rr.Code, http.StatusOK) + } + if !tt.want && rr.Code != http.StatusForbidden { + t.Fatalf("middleware failed to reject rewritten-Host request: got %v want %v", + rr.Code, http.StatusForbidden) + } + }) + } +} diff --git a/helpers.go b/helpers.go index 99005ee..dea2d52 100644 --- a/helpers.go +++ b/helpers.go @@ -158,6 +158,14 @@ func sameOrigin(a, b *url.URL) bool { return (a.Scheme == b.Scheme && a.Host == b.Host) } +// sameOriginFetch reports whether the browser labeled the request as +// same-origin via Sec-Fetch-Site. Browsers set this forbidden header; +// scripts cannot. Reverse proxies that rewrite Host still leave +// Sec-Fetch-Site=same-origin on genuine same-origin form posts. +func sameOriginFetch(r *http.Request) bool { + return r.Header.Get("Sec-Fetch-Site") == "same-origin" +} + // compare securely (constant-time) compares the unmasked token from the request // against the real token from the session. func compareTokens(a, b []byte) bool {