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
265 lines
7.2 KiB
Go
265 lines
7.2 KiB
Go
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
|
|
}
|