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