The earlier entry posted to an app without uploads, where the route does not exist, and stayed green with the route removed. This one seeds a finished row and goes red without the gate. Found by the PR #58 review. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
152 lines
5.0 KiB
Go
152 lines
5.0 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossOrigin_ARefusedRecordingDeletePostLeavesTheRecordingInPlace(t *testing.T) {
|
|
for name, h := range foreign {
|
|
t.Run(name, func(t *testing.T) {
|
|
app, _ := newUploadApp(t, audioOK, 200<<20)
|
|
id := seedUpload(t, userID, "m", "done", "Synthetic transcript text.")
|
|
|
|
rec := do(t, app, withHeaders(httptest.NewRequest(http.MethodPost, "/uploads/"+id+"/delete", nil), h))
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
assert.True(t, uploadExists(t, id), "a refused post must not delete the recording")
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|