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) } }