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: claude-opus-4-8
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user