From 10b4c43bd2b21f04cecca1ce0603dd9808463b79 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 3 Oct 2026 20:55:00 +0200 Subject: [PATCH] test(web): foreign-origin POSTs must be refused by the router (#48), red Co-Authored-By: Claude Sonnet 5.5 --- internal/web/crossorigin_test.go | 137 +++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 internal/web/crossorigin_test.go diff --git a/internal/web/crossorigin_test.go b/internal/web/crossorigin_test.go new file mode 100644 index 0000000..0d5e915 --- /dev/null +++ b/internal/web/crossorigin_test.go @@ -0,0 +1,137 @@ +package web_test + +// Observes the cross-origin gate around Router (#48): SameSite=Lax does not stop a POST +// from a sibling *.d-ma.be origin, so the router itself must refuse a non-safe request +// whose Origin or Sec-Fetch-Site says it did not come from this site's own pages. + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// foreign is a header set a browser sends for a form post made by another page. +var foreign = map[string]map[string]string{ + "sibling subdomain, Sec-Fetch-Site": {"Origin": "https://evil.d-ma.be", "Sec-Fetch-Site": "same-site"}, + "other site, Sec-Fetch-Site": {"Origin": "https://evil.example", "Sec-Fetch-Site": "cross-site"}, + "sibling subdomain, Origin only": {"Origin": "https://evil.d-ma.be"}, + "opaque origin": {"Origin": "null"}, +} + +// own is a header set for the app's own pages (or a non-browser client): must pass. +var own = map[string]map[string]string{ + "same-origin Sec-Fetch-Site": {"Sec-Fetch-Site": "same-origin", "Origin": "http://example.com"}, + "matching Origin only": {"Origin": "http://example.com"}, + "no browser headers": {}, +} + +func withHeaders(req *http.Request, h map[string]string) *http.Request { + for k, v := range h { + req.Header.Set(k, v) + } + return req +} + +func userCount(t *testing.T) int { + t.Helper() + var n int + require.NoError(t, rawPool(t).QueryRow(context.Background(), `SELECT count(*) FROM users WHERE id = $1`, userID).Scan(&n)) + return n +} + +func TestCrossOrigin_ARefusedDeleteAccountPostLeavesTheAccountInPlace(t *testing.T) { + for name, h := range foreign { + t.Run(name, func(t *testing.T) { + app, _ := newAccountApp(t) + resetDB(t, rawPool(t)) + seedConnection(t, app, "youtube", "ada@channel", "ref") + + rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/account/delete", nil), h)) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Equal(t, 1, userCount(t), "the account must survive a refused post") + }) + } +} + +func TestCrossOrigin_ASameOriginOrHeaderlessDeleteAccountPostIsServed(t *testing.T) { + for name, h := range own { + t.Run(name, func(t *testing.T) { + app, _ := newAccountApp(t) + resetDB(t, rawPool(t)) + seedConnection(t, app, "youtube", "ada@channel", "ref") + + rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/account/delete", nil), h)) + + assert.Equal(t, http.StatusSeeOther, rec.Code) + assert.Equal(t, "/auth/logout", rec.Header().Get("Location")) + }) + } +} + +func TestCrossOrigin_ARefusedUploadPostSubmitsNoJob(t *testing.T) { + for name, h := range foreign { + t.Run(name, func(t *testing.T) { + app, sub := newUploadApp(t, audioOK, 200<<20) + + rec := do(t, app, withHeaders(uploadReq(t, "m", "m.wav", []byte("RIFF....WAVE")), h)) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Empty(t, sub.jobs, "a refused post must not enqueue transcription") + }) + } +} + +func TestCrossOrigin_ARefusedUploadChatPostCallsNoModel(t *testing.T) { + for name, h := range foreign { + t.Run(name, func(t *testing.T) { + app, fc := newUploadChatApp(t) + id := seedUpload(t, userID, "Roadmap sync", "done", meetingText) + + req := chatPost("/uploads/"+id+"/chat", url.Values{"message": {"hej"}}) + rec := do(t, app, withHeaders(req, h)) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Zero(t, fc.callCount) + }) + } +} + +func TestCrossOrigin_AForeignPostIsRefusedOnEachListedStateChangingPath(t *testing.T) { + routes := []string{ + "/account/disconnect/youtube", + "/account/summarize-mode", + "/register", + "/paste", + "/v/abc/action", + "/v/abc/summarize", + "/v/abc/retry-now", + "/v/abc/chat", + } + for _, path := range routes { + t.Run(path, func(t *testing.T) { + app := newApp(t) + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader("")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := do(t, app, withHeaders(req, foreign["sibling subdomain, Sec-Fetch-Site"])) + assert.Equal(t, http.StatusForbidden, rec.Code) + }) + } +} + +// A login redirect comes back to /auth/callback as a cross-site GET navigation, and +// health probes carry no headers: safe methods must never be refused on origin grounds. +func TestCrossOrigin_ACrossSiteGETIsNotRefused(t *testing.T) { + app := newApp(t) + for _, path := range []string{"/healthz", "/welcome"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Sec-Fetch-Site", "cross-site") + assert.Equal(t, http.StatusOK, do(t, app, req).Code, path) + } +}