From 10467e2d6d660e24480e0e805ba34c932976983f Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 16:45:00 +0000 Subject: [PATCH] test: CSRF rejection/acceptance for POST / and POST /generate Failing tests (TDD) for the two cookie-authenticated HTML form posts: a POST without a CSRF token is rejected, a token that does not match the request's CSRF cookie is rejected, and a matching cookie+token succeeds. Login CSRF is covered specifically: the POST / cases carry no session, so protection rests on a token bound to a pre-session cookie. These reference production symbols not yet added (newCSRFProtect, Handlers.CSRF, the csrfProtect field), so the package does not build until the implementation lands. Model: opus-4-8 --- go.mod | 1 + go.sum | 2 + internal/handlers/auth_csrf_internal_test.go | 264 +++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 internal/handlers/auth_csrf_internal_test.go diff --git a/go.mod b/go.mod index ce9b477..8c31a7c 100644 --- a/go.mod +++ b/go.mod @@ -71,6 +71,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.2 // indirect + github.com/gorilla/csrf v1.7.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/hashicorp/consul/api v1.32.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/go.sum b/go.sum index 002a2b4..af3e229 100644 --- a/go.sum +++ b/go.sum @@ -175,6 +175,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= +github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0= +github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= diff --git a/internal/handlers/auth_csrf_internal_test.go b/internal/handlers/auth_csrf_internal_test.go new file mode 100644 index 0000000..2db775a --- /dev/null +++ b/internal/handlers/auth_csrf_internal_test.go @@ -0,0 +1,264 @@ +package handlers + +import ( + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "regexp" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "sneak.berlin/go/pixa/internal/config" + "sneak.berlin/go/pixa/internal/encurl" + "sneak.berlin/go/pixa/internal/session" +) + +// testSigningKey is a throwaway signing key for the CSRF flow tests. It +// seeds the session manager, the encrypted-URL generator, and the CSRF +// token key, exactly as the real signing key does in production. +const testSigningKey = "test-signing-key-0123456789abcdef" + +// csrfFieldPattern extracts the token rendered by csrf.TemplateField into +// the form. The field name is gorilla/csrf's default. +var csrfFieldPattern = regexp.MustCompile( + `name="gorilla\.csrf\.Token" value="([^"]+)"`) + +// newCSRFTestRouter builds a router that mirrors the production wiring for +// the CSRF-protected UI routes (see server.SetupRoutes): the login and +// generator forms and their POST targets sit behind the real CSRF +// middleware. Requests are marked plaintext (Debug: true) so the flow runs +// over httptest's http transport without an https Referer. +func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) { + t.Helper() + + cfg := &config.Config{SigningKey: testSigningKey, Debug: true} + + sessMgr, err := session.NewManager(testSigningKey) + if err != nil { + t.Fatalf("session.NewManager() error = %v", err) + } + + encGen, err := encurl.NewGenerator(testSigningKey) + if err != nil { + t.Fatalf("encurl.NewGenerator() error = %v", err) + } + + protect, err := newCSRFProtect(testSigningKey, cfg.Debug) + if err != nil { + t.Fatalf("newCSRFProtect() error = %v", err) + } + + h := &Handlers{ + log: slog.New(slog.NewTextHandler(io.Discard, nil)), + config: cfg, + sessMgr: sessMgr, + encGen: encGen, + csrfProtect: protect, + } + + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Use(h.CSRF()) + r.Get("/", h.HandleRoot()) + r.Post("/", h.HandleRoot()) + r.Post("/generate", h.HandleGenerateURL()) + }) + + return h, r +} + +// csrfCredentials performs a GET that renders a form and returns the CSRF +// cookies the middleware set and the token embedded in the form. Passing +// the authenticated session cookie renders the generator form instead of +// the login form. +func csrfCredentials( + t *testing.T, srv http.Handler, reqCookies []*http.Cookie, +) ([]*http.Cookie, string) { + t.Helper() + + req := httptest.NewRequest(http.MethodGet, "/", nil) + for _, c := range reqCookies { + req.AddCookie(c) + } + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK) + } + + match := csrfFieldPattern.FindStringSubmatch(rec.Body.String()) + if match == nil { + t.Fatalf("no CSRF token field found in rendered form") + } + + return rec.Result().Cookies(), match[1] +} + +// postForm submits form values with the given cookies and returns the +// recorder. +func postForm( + srv http.Handler, path string, + cookies []*http.Cookie, form url.Values, +) *httptest.ResponseRecorder { + req := httptest.NewRequest( + http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + for _, c := range cookies { + req.AddCookie(c) + } + + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + + return rec +} + +// TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token +// is rejected. This is login CSRF: no session cookie exists yet, so the +// protection must rest on a token bound to a pre-session cookie. +func TestLoginPostRejectedWithoutToken(t *testing.T) { + t.Parallel() + + _, srv := newCSRFTestRouter(t) + + rec := postForm(srv, "/", nil, url.Values{"key": {testSigningKey}}) + + if rec.Code != http.StatusForbidden { + t.Errorf("POST / without token status = %d, want %d", + rec.Code, http.StatusForbidden) + } +} + +// TestLoginPostRejectedWithForeignToken verifies that a token that does not +// match the request's CSRF cookie is rejected: a token minted for one +// cookie cannot authorize a request carrying a different cookie. +func TestLoginPostRejectedWithForeignToken(t *testing.T) { + t.Parallel() + + _, srv := newCSRFTestRouter(t) + + cookiesA, _ := csrfCredentials(t, srv, nil) + _, tokenB := csrfCredentials(t, srv, nil) + + rec := postForm(srv, "/", cookiesA, url.Values{ + "key": {testSigningKey}, + "gorilla.csrf.Token": {tokenB}, + }) + + if rec.Code != http.StatusForbidden { + t.Errorf("POST / with foreign token status = %d, want %d", + rec.Code, http.StatusForbidden) + } +} + +// TestLoginPostAcceptedWithValidToken verifies that POST / with a matching +// cookie and token succeeds: the login is processed and a session is +// established (303 redirect). +func TestLoginPostAcceptedWithValidToken(t *testing.T) { + t.Parallel() + + _, srv := newCSRFTestRouter(t) + + cookies, token := csrfCredentials(t, srv, nil) + + rec := postForm(srv, "/", cookies, url.Values{ + "key": {testSigningKey}, + "gorilla.csrf.Token": {token}, + }) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("POST / with valid token status = %d, want %d", + rec.Code, http.StatusSeeOther) + } + + var authed bool + + for _, c := range rec.Result().Cookies() { + if c.Name == session.CookieName && c.Value != "" { + authed = true + } + } + + if !authed { + t.Error("valid login did not set a session cookie") + } +} + +// TestGeneratePostRejectedWithoutToken verifies that POST /generate is +// rejected without a CSRF token even when the request carries a valid +// authenticated session. The session cookie is not sufficient; the policy +// requires a CSRF token on this cookie-authenticated form. +func TestGeneratePostRejectedWithoutToken(t *testing.T) { + t.Parallel() + + h, srv := newCSRFTestRouter(t) + + sessionCookie := newSessionCookie(t, h) + + rec := postForm(srv, "/generate", + []*http.Cookie{sessionCookie}, + url.Values{"url": {"https://example.com/a.jpg"}}) + + if rec.Code != http.StatusForbidden { + t.Errorf("POST /generate without token status = %d, want %d", + rec.Code, http.StatusForbidden) + } +} + +// TestGeneratePostAcceptedWithValidToken verifies that POST /generate +// succeeds with a valid session and a matching CSRF cookie and token. +func TestGeneratePostAcceptedWithValidToken(t *testing.T) { + t.Parallel() + + h, srv := newCSRFTestRouter(t) + + sessionCookie := newSessionCookie(t, h) + + cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie}) + cookies = append(cookies, sessionCookie) + + rec := postForm(srv, "/generate", cookies, url.Values{ + "url": {"https://example.com/a.jpg"}, + "format": {"jpeg"}, + "gorilla.csrf.Token": {token}, + }) + + if rec.Code != http.StatusOK { + t.Fatalf("POST /generate with valid token status = %d, want %d", + rec.Code, http.StatusOK) + } + + if !strings.Contains(rec.Body.String(), "/v1/e/") { + t.Error("generator response did not contain a generated URL") + } +} + +// newSessionCookie creates an authenticated session cookie via the +// handler's session manager. +func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie { + t.Helper() + + rec := httptest.NewRecorder() + + err := h.sessMgr.CreateSession(rec) + if err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + + for _, c := range rec.Result().Cookies() { + if c.Name == session.CookieName { + return c + } + } + + t.Fatalf("session manager did not set a %q cookie", session.CookieName) + + return nil +}