From 7750b8fbb0a9ed1994b531041d9b3b79b4551c81 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Fri, 17 Apr 2026 02:30:31 +0100 Subject: [PATCH] Wrap router with http.CrossOriginProtection for CSRF defence Add Go 1.25's http.NewCrossOriginProtection().Handler to the global middleware chain. Previously the only CSRF defence was SameSite=Strict on the session cookie, which Firefox does not enforce by default and which subdomain attacks can bypass. The middleware checks Sec-Fetch-Site (forbidden header, set by all major browsers since 2023) with an Origin/Host fallback. Safe methods and non-browser POSTs (no Sec-Fetch-Site header, e.g. curl/scripts hitting /api/v1/) pass through unchanged. --- app/server/server.go | 1 + app/server/server_test.go | 50 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/app/server/server.go b/app/server/server.go index dac5c79..43948dc 100644 --- a/app/server/server.go +++ b/app/server/server.go @@ -197,6 +197,7 @@ func (s Server) routes() http.Handler { rest.Ping, rest.SizeLimit(sizeLimit), tollbooth.HTTPMiddleware(tollbooth.NewLimiter(10, nil)), + http.NewCrossOriginProtection().Handler, ) // security headers - enabled by default, disabled with --proxy-security-headers diff --git a/app/server/server_test.go b/app/server/server_test.go index 2e91474..f55cbf5 100644 --- a/app/server/server_test.go +++ b/app/server/server_test.go @@ -699,6 +699,56 @@ func TestServer_CopyFeedback(t *testing.T) { assert.Contains(t, string(body), "Content copied!") } +func TestServer_crossOriginProtection(t *testing.T) { + ts, teardown := prepTestServer(t) + defer teardown() + + tests := []struct { + name string + path string + body string + secFetchSite string + origin string + wantForbidden bool + }{ + {name: "POST same-origin allowed", path: "/theme", + body: "theme=dark", secFetchSite: "same-origin"}, + {name: "POST none allowed (direct nav)", path: "/theme", + body: "theme=dark", secFetchSite: "none"}, + {name: "POST cross-site rejected", path: "/theme", + body: "theme=dark", secFetchSite: "cross-site", wantForbidden: true}, + {name: "POST same-site rejected (subdomain)", path: "/theme", + body: "theme=dark", secFetchSite: "same-site", wantForbidden: true}, + {name: "POST origin mismatch rejected", path: "/theme", + body: "theme=dark", origin: "http://evil.com", wantForbidden: true}, + {name: "POST API non-browser allowed", path: "/api/v1/message", + body: `{"message":"x","exp":600,"pin":"12345"}`}, + {name: "POST API cross-site rejected", path: "/api/v1/message", + body: `{"message":"x","exp":600,"pin":"12345"}`, secFetchSite: "cross-site", wantForbidden: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, err := http.NewRequest("POST", ts.URL+tt.path, strings.NewReader(tt.body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + if tt.secFetchSite != "" { + req.Header.Set("Sec-Fetch-Site", tt.secFetchSite) + } + if tt.origin != "" { + req.Header.Set("Origin", tt.origin) + } + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + if tt.wantForbidden { + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "should be rejected as cross-origin") + return + } + assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "should not be rejected as cross-origin") + }) + } +} + func prepTestServer(t *testing.T) (ts *httptest.Server, teardown func()) { eng := store.NewInMemory(time.Second)