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.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user