Cover shared origins and scope CSRF to credential handlers
2 files changed,
+30
−7
internal/home/app.go+6 −7internal/home/origins_test.go+24 −0
diff --git a/internal/home/app.go b/internal/home/app.go| index ef62fdd..bec990a 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -130,14 +130,7 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) { |
| func (a *App) Router() http.Handler { |
| r := chi.NewRouter() |
| - // Router-wide, not just on the verify POST. POST /signin needs it |
| - // too: it is an unauthenticated endpoint that sends mail and makes |
| - // outbound DNS and HTTPS calls decided by the submitted address — a |
| - // blind-SSRF surface by design — and csrf.Protect is pure origin |
| - // evidence (Sec-Fetch-Site, then Origin, then Referer) that never |
| - // consults a session, so it works before anybody is signed in. |
| r.Use(serveKnownHost) |
| - r.Use(csrf.Protect(Origin)) |
| r.Get("/", func(w http.ResponseWriter, r *http.Request) { |
| if r.Host == credentialHost { |
| @@ -160,6 +153,12 @@ func (a *App) Router() http.Handler { |
| r.Group(func(r chi.Router) { |
| r.Use(credentialOnly) |
| + // POST /signin is an unauthenticated endpoint that sends mail and makes |
| + // outbound DNS and HTTPS calls decided by the submitted address — a |
| + // blind-SSRF surface by design — and csrf.Protect is pure origin |
| + // evidence (Sec-Fetch-Site, then Origin, then Referer) that never |
| + // consults a session, so it works before anybody is signed in. |
| + r.Use(csrf.Protect(Origin)) |
| r.Get("/signin", a.handleSigninForm) |
| r.Post("/signin", a.handleSigninBegin) |
| r.Get("/signin/sent", a.handleSigninSent) |
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go| index 6392707..6438875 100644 |
| --- a/internal/home/origins_test.go |
| +++ b/internal/home/origins_test.go |
| @@ -75,3 +75,27 @@ func TestDirectoryIsNotWithinCredentialRP(t *testing.T) { |
| t.Fatal("directory overlaps the credential origin or relying party") |
| } |
| } |
| + |
| +func TestSharedRoutesOnBothOrigins(t *testing.T) { |
| + app := newTestApp(t) |
| + for _, host := range []string{credentialHost, directoryHost} { |
| + for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} { |
| + req := httptest.NewRequest(http.MethodGet, "https://"+host+path, nil) |
| + rec := httptest.NewRecorder() |
| + app.Router().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 { |
| + t.Errorf("host=%s path=%s status=%d cookies=%d", host, path, rec.Code, len(rec.Result().Cookies())) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestDirectoryCredentialPostRefusedBeforeCSRF(t *testing.T) { |
| + app := newTestApp(t) |
| + req := httptest.NewRequest(http.MethodPost, directoryOrigin+"/signin", nil) |
| + rec := httptest.NewRecorder() |
| + app.Router().ServeHTTP(rec, req) |
| + if rec.Code != http.StatusNotFound || rec.Body.Len() != 0 { |
| + t.Fatalf("directory credential POST status=%d body=%q", rec.Code, rec.Body.String()) |
| + } |
| +} |