feat: CSRF protection on the login and URL-generator forms (closes #93)
check / check (push) Failing after 0s

Both cookie-authenticated HTML form posts (POST / and POST /generate)
now require a CSRF token via gorilla/csrf, the recorded default in
GO_PACKAGE_DEFAULTS.md. The token cookie is independent of the session
cookie, so it also covers the login POST, where no session exists yet
(login CSRF). The token key is derived from the signing key with its own
HKDF salt, so tokens survive restarts and reuse no other key material;
gorilla/csrf supplies crypto/rand generation and constant-time compare.

The form routes sit in a chi group behind the middleware; the hidden
token field is rendered into login.html and generator.html. In local
plaintext HTTP mode (debug) requests are marked plaintext so the library
does not demand an https Referer or set a Secure cookie the browser
would withhold; in production, behind the TLS-terminating proxy, it
enforces its https Referer origin check.

model: claude-opus-4-8
This commit is contained in:
2026-09-21 12:57:23 +00:00
parent 5f88dc5cfa
commit 6a753f14c4
8 changed files with 144 additions and 45 deletions
+22 -13
View File
@@ -1,7 +1,7 @@
package handlers
import (
"io"
"context"
"log/slog"
"net/http"
"net/http/httptest"
@@ -22,6 +22,13 @@ import (
// token key, exactly as the real signing key does in production.
const testSigningKey = "test-signing-key-0123456789abcdef"
// Form field names used in the CSRF flow tests.
const (
loginKeyField = "key"
// gorilla/csrf's default form field name, not a credential.
csrfTokenField = "gorilla.csrf.Token" //nolint:gosec // G101 false positive
)
// csrfFieldPattern extracts the token rendered by csrf.TemplateField into
// the form. The field name is gorilla/csrf's default.
var csrfFieldPattern = regexp.MustCompile(
@@ -53,7 +60,7 @@ func newCSRFTestRouter(t *testing.T) (*Handlers, http.Handler) {
}
h := &Handlers{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
log: slog.New(slog.DiscardHandler),
config: cfg,
sessMgr: sessMgr,
encGen: encGen,
@@ -80,7 +87,8 @@ func csrfCredentials(
) ([]*http.Cookie, string) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/", nil)
req := httptest.NewRequestWithContext(
context.Background(), http.MethodGet, "/", nil)
for _, c := range reqCookies {
req.AddCookie(c)
}
@@ -106,8 +114,9 @@ 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 := httptest.NewRequestWithContext(
context.Background(), http.MethodPost, path,
strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookies {
@@ -128,7 +137,7 @@ func TestLoginPostRejectedWithoutToken(t *testing.T) {
_, srv := newCSRFTestRouter(t)
rec := postForm(srv, "/", nil, url.Values{"key": {testSigningKey}})
rec := postForm(srv, "/", nil, url.Values{loginKeyField: {testSigningKey}})
if rec.Code != http.StatusForbidden {
t.Errorf("POST / without token status = %d, want %d",
@@ -148,8 +157,8 @@ func TestLoginPostRejectedWithForeignToken(t *testing.T) {
_, tokenB := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookiesA, url.Values{
"key": {testSigningKey},
"gorilla.csrf.Token": {tokenB},
loginKeyField: {testSigningKey},
csrfTokenField: {tokenB},
})
if rec.Code != http.StatusForbidden {
@@ -169,8 +178,8 @@ func TestLoginPostAcceptedWithValidToken(t *testing.T) {
cookies, token := csrfCredentials(t, srv, nil)
rec := postForm(srv, "/", cookies, url.Values{
"key": {testSigningKey},
"gorilla.csrf.Token": {token},
loginKeyField: {testSigningKey},
csrfTokenField: {token},
})
if rec.Code != http.StatusSeeOther {
@@ -225,9 +234,9 @@ func TestGeneratePostAcceptedWithValidToken(t *testing.T) {
cookies = append(cookies, sessionCookie)
rec := postForm(srv, "/generate", cookies, url.Values{
"url": {"https://example.com/a.jpg"},
"format": {"jpeg"},
"gorilla.csrf.Token": {token},
"url": {"https://example.com/a.jpg"},
"format": {"jpeg"},
csrfTokenField: {token},
})
if rec.Code != http.StatusOK {