From 4eaf8ae3669a87619afbdf4d045944b9b8d44377 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:54:38 -0700 Subject: [PATCH] web: refuse writes that come from somebody else's page The session cookie is scoped to the parent domain and is SameSite=Lax, and every *.parodia.dev host is the same site as Pete as far as Lax is concerned. So a form on any sibling subdomain could post a bet, a mischief order or an equip change and the browser would attach whoever's signed-in cookie to it. Nothing on the server looked at where a request came from. The whole handler now sits behind http.NewCrossOriginProtection. It reads Sec-Fetch-Site (falling back to Origin against Host) and refuses a state-changing request from any other origin with a 403. GETs are untouched, and so is anything that carries neither header, which is exactly what gogobee's bearer-token calls from Go look like. --- internal/web/csrf_test.go | 51 +++++++++++++++++++++++++++++++++++++++ internal/web/server.go | 9 ++++++- 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 internal/web/csrf_test.go diff --git a/internal/web/csrf_test.go b/internal/web/csrf_test.go new file mode 100644 index 0000000..d8de3a0 --- /dev/null +++ b/internal/web/csrf_test.go @@ -0,0 +1,51 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "pete/internal/config" + "pete/internal/storage" +) + +// A sibling parodia.dev host is the same *site* as Pete, so SameSite=Lax lets +// the session cookie ride along on its forms. The server has to refuse those +// itself, and must still let a same-origin page and a headerless server-to-server +// call (gogobee, with its bearer token) through. +func TestCrossOriginWritesAreRefused(t *testing.T) { + storage.Close() + if err := storage.Init(filepath.Join(t.TempDir(), "csrf.db")); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { storage.Close() }) + + s, err := New(config.WebConfig{SiteTitle: "Pete", ListenAddr: ":0"}, nil, true, config.AdventureConfig{}, nil) + if err != nil { + t.Fatal(err) + } + + post := func(hdr map[string]string) int { + req := httptest.NewRequest("POST", "https://news.parodia.dev/api/state/read", nil) + for k, v := range hdr { + req.Header.Set(k, v) + } + rec := httptest.NewRecorder() + s.srv.Handler.ServeHTTP(rec, req) + return rec.Code + } + + if code := post(map[string]string{"Sec-Fetch-Site": "cross-site"}); code != http.StatusForbidden { + t.Errorf("cross-site POST = %d, want 403", code) + } + if code := post(map[string]string{"Sec-Fetch-Site": "same-site", "Origin": "https://evil.parodia.dev"}); code != http.StatusForbidden { + t.Errorf("sibling-subdomain POST = %d, want 403", code) + } + if code := post(map[string]string{"Sec-Fetch-Site": "same-origin"}); code == http.StatusForbidden { + t.Error("same-origin POST was refused") + } + if code := post(nil); code == http.StatusForbidden { + t.Error("headerless server-to-server POST was refused") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index f0eb73d..d341985 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -353,9 +353,16 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo } } + // Every state-changing request from a browser must come from this site's + // own origin. The session cookie is scoped to the parent domain and is + // SameSite=Lax, and every *.parodia.dev host counts as the same site, so + // without this a form on any sibling subdomain could bet, order or equip as + // whoever is signed in. It keys off Sec-Fetch-Site (falling back to Origin + // vs Host) and passes requests carrying neither, so gogobee's bearer-token + // calls from Go are untouched. GET/HEAD/OPTIONS are never blocked. s.srv = &http.Server{ Addr: cfg.ListenAddr, - Handler: s.hostRouter(mux), + Handler: http.NewCrossOriginProtection().Handler(s.hostRouter(mux)), ReadHeaderTimeout: 5 * time.Second, } return s, nil