| phase |
plan |
type |
wave |
depends_on |
files_modified |
autonomous |
requirements |
tags |
must_haves |
| 02-authentication |
07 |
execute |
6 |
|
| backend/internal/auth/csrf.go |
| backend/internal/web/ui/csrf_field.templ |
| backend/internal/web/router.go |
| backend/internal/web/handlers_auth.go |
| backend/internal/web/handlers_auth_test.go |
| backend/internal/web/handlers.go |
| backend/templates/auth_login.templ |
| backend/templates/auth_signup.templ |
| backend/templates/auth_form_errors.templ |
| backend/templates/layout.templ |
| backend/templates/index.templ |
| backend/cmd/web/main.go |
| backend/.env.example |
| backend/go.mod |
| backend/go.sum |
|
true |
|
| go |
| csrf |
| gorilla |
| htmx |
| templ |
| security-hardening |
|
| truths |
artifacts |
key_links |
| gorilla/csrf v1.7.3 middleware is mounted on the chi stack AFTER ResolveSession and BEFORE any route group (D-14, D-24) |
| csrf.Protect is configured with Secure(env != "dev"), SameSite(csrf.SameSiteLaxMode), Path("/"), FieldName("_csrf"), RequestHeader("X-CSRF-Token") (D-14) |
| The csrf authentication key is a 32-byte value loaded from env SESSION_SECRET; main.go fails fast if it is missing or wrong length (D-15 "key MUST come from env") |
| A reusable templ component ui.CSRFField(token) renders <input type="hidden" name="_csrf" value="{token}"/> (D-15) |
| Every templ form (signup, login, login fragment, signup fragment, layout logout) embeds @ui.CSRFField(token) as the first child of <form> |
| GET handlers that render forms (signup page, login page, fragments, layout) thread csrf.Token(r) into the templ Layout/page calls |
| POST /signup, POST /login, POST /logout without a valid _csrf token return 403 Forbidden (gorilla/csrf default response) |
| POST /signup, POST /login, POST /logout WITH a valid _csrf token continue to the normal flow (200/303 etc.) |
| Handlers read form fields via r.PostFormValue ONLY (never re-reading r.Body) so they coexist with gorilla/csrf's body consumption (Pitfall 1) |
|
| path |
provides |
contains |
| backend/internal/auth/csrf.go |
Mount(env string, secret []byte) func(http.Handler) http.Handler helper that constructs csrf.Protect with the locked options |
csrf.Protect |
|
| path |
provides |
contains |
| backend/internal/web/ui/csrf_field.templ |
ui.CSRFField(token string) templ component |
templ CSRFField |
|
| path |
provides |
| backend/internal/web/router.go |
r.Use(auth.Mount(env, csrfKey)) inserted after ResolveSession and before route groups |
|
| path |
provides |
| backend/cmd/web/main.go |
Loads SESSION_SECRET from env (hex-decode 32 bytes); fails fast on missing/short key; passes []byte secret + env into NewRouter |
|
| path |
provides |
| backend/.env.example |
Documents SESSION_SECRET with `openssl rand -hex 32` instruction |
|
|
| from |
to |
via |
pattern |
| backend/internal/web/router.go |
backend/internal/auth/csrf.go |
r.Use(auth.Mount(env, csrfKey)) immediately after ResolveSession |
auth.Mount|csrf.Protect |
|
| from |
to |
via |
pattern |
| backend/templates/layout.templ |
backend/internal/web/ui/csrf_field.templ |
@ui.CSRFField(csrfToken) inside the logout form when user != nil |
ui.CSRFField|CSRFField( |
|
| from |
to |
via |
pattern |
| backend/internal/web/handlers_auth.go |
backend/internal/web/ui/csrf_field.templ |
csrf.Token(r) -> templ page/fragment arg -> @ui.CSRFField(token) |
csrf.Token(r) |
|
|
|
Mount gorilla/csrf on the chi stack and wire every templ form to embed the hidden _csrf field. Close AUTH-06.
Purpose: AUTH-06 closure. Before this plan, POST /signup, POST /login, POST /logout work without a CSRF check — meaning a malicious cross-origin or stale-tab POST could trigger state changes. After this plan, every state-changing request requires a valid token, and every form ships one by construction. SameSite=Lax provides an interim defense before this lands; gorilla/csrf is the real fix.
Output: csrf-protected POST routes, a reusable @ui.CSRFField templ component, env-driven csrf key, and integration tests proving 403-on-missing / 200-on-valid for all three POST routes.
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
@.planning/phases/02-authentication/02-CONTEXT.md
@.planning/phases/02-authentication/02-RESEARCH.md
@.planning/phases/02-authentication/02-04-PLAN.md
@.planning/phases/02-authentication/02-05-PLAN.md
@.planning/phases/02-authentication/02-06-PLAN.md
@backend/internal/web/router.go
@backend/internal/web/handlers_auth.go
@backend/internal/web/handlers.go
@backend/templates/layout.templ
@backend/templates/auth_login.templ
@backend/templates/auth_signup.templ
@backend/templates/auth_form_errors.templ
@backend/templates/index.templ
@backend/cmd/web/main.go
@backend/.env.example
New / changed surfaces this plan introduces:
-
backend/internal/auth/csrf.go:
func Mount(env string, key []byte) func(http.Handler) http.Handler — wraps csrf.Protect(key, csrf.Secure(env != "dev"), csrf.SameSite(csrf.SameSiteLaxMode), csrf.Path("/"), csrf.FieldName("_csrf"), csrf.RequestHeader("X-CSRF-Token")). Returned middleware is what gets passed to r.Use(...).
-
backend/internal/web/ui/csrf_field.templ:
templ CSRFField(token string) { <input type="hidden" name="_csrf" value={ token }/> }
- Lives alongside the existing Button/Card/Badge components established in Phase 1.
-
Every templ form-rendering helper acquires a csrfToken string arg:
templ LoginPage(token string, errors map[string]string)
templ LoginFormFragment(token string, errors map[string]string)
templ SignupPage(token string, errors map[string]string)
templ SignupFormFragment(token string, errors map[string]string)
templ Layout(title string, user *auth.User, csrfToken string) — the logout form needs the token even though the page itself may be the Index.
templ Index(user *auth.User, csrfToken string)
-
Handlers thread csrf.Token(r) into every page/fragment render call:
LoginPageHandler, SignupPageHandler, IndexHandler and the error-fragment branches of LoginPostHandler / SignupPostHandler.
-
cmd/web/main.go loads SESSION_SECRET (hex-encoded 32 bytes) → hex.DecodeString → []byte; logs a fatal error if missing or len != 32. Pass the []byte and env string into NewRouter.
-
NewRouter signature acquires csrfKey []byte and env string params (if not already present from earlier plans) and calls r.Use(auth.Mount(env, csrfKey)) immediately after r.Use(auth.ResolveSession(store, cookieName)).
Task 1: Mount gorilla/csrf, add ui.CSRFField, wire every form, env-driven key
backend/internal/web/router.go
backend/internal/web/handlers_auth.go
backend/internal/web/handlers.go
backend/templates/layout.templ
backend/templates/auth_login.templ
backend/templates/auth_signup.templ
backend/templates/auth_form_errors.templ
backend/templates/index.templ
backend/cmd/web/main.go
backend/.env.example
backend/internal/web/ui/ (existing Button/Card/Badge components — match the directory layout and templ file style)
.planning/phases/02-authentication/02-RESEARCH.md (Pattern 7 — gorilla/csrf integration; Pitfall 1 — body consumption; Pitfall 2 — token absent on GET-rendered forms; Pitfall 7 — middleware order)
.planning/phases/02-authentication/02-CONTEXT.md (D-14, D-15, D-24)
.planning/phases/02-authentication/02-06-PLAN.md (Layout signature change to *auth.User; this plan extends it with csrfToken)
Tests added to backend/internal/web/handlers_auth_test.go (extending Plans 04/05/06 suite):
- TestCSRF_LoginMissingToken: POST /login with valid email+password but NO `_csrf` field → 403. No session row created.
- TestCSRF_LoginValidToken: GET /login first to receive the csrf cookie; parse the cookie + render-extracted token; POST /login with `_csrf` set → 200/303 (normal flow). Pre-seeded user with known argon2 password.
- TestCSRF_SignupMissingToken: POST /signup without `_csrf` → 403. SELECT COUNT(*) FROM users WHERE email = $1 == 0.
- TestCSRF_SignupValidToken: GET /signup → POST /signup with valid token → 303 to /, user row inserted.
- TestCSRF_LogoutMissingToken: pre-seed session; POST /logout WITH session cookie but WITHOUT _csrf → 403. SELECT COUNT(*) FROM sessions WHERE id = $1 == 1 (NOT deleted).
- TestCSRF_LogoutValidToken: full GET / → POST /logout with token → 303, session row deleted.
- TestCSRF_HeaderFallback: POST /login with `X-CSRF-Token` header (no form field) → token accepted (verifies `csrf.RequestHeader("X-CSRF-Token")` wiring for future HTMX hx-headers usage).
- TestForms_ContainCSRFField (templ smoke): render LoginPage("abc", nil), SignupPage("abc", nil), Layout("X", &auth.User{Email:"x@y"}, "abc") to a buffer and assert each contains `name="_csrf"` AND `value="abc"`. Login fragment and signup fragment also covered.
- TestRouter_CSRFMountedAfterResolveSession: a unit-level inspection — assert that NewRouter wires `auth.ResolveSession` before `auth.Mount` (D-24). Implementation: render a request that proves order, OR (simpler) read router.go source via the test using os.ReadFile and assert the substring `ResolveSession` appears before `auth.Mount` / `csrf.Protect`. Choose the source-file scan — deterministic and cheap.
- TestMain_FailsFastOnMissingSecret: this is a build-time / startup-time assertion — added as a `TestLoadCSRFKey_*` set in a new `backend/cmd/web/main_test.go` (or `backend/internal/web/csrf_key_test.go` if main.go is too small to support a separate test target). Cases: missing env → error; len != 32 → error; valid hex 64-char → returns []byte{32}.
1. `go get github.com/gorilla/csrf@v1.7.3` (run inside backend/). Confirm go.sum updated.
2. Create `backend/internal/auth/csrf.go`:
- Package `auth`. Import `github.com/gorilla/csrf` and `net/http`.
- Export `func Mount(env string, key []byte) func(http.Handler) http.Handler` that returns
`csrf.Protect(key, csrf.Secure(env != "dev"), csrf.SameSite(csrf.SameSiteLaxMode), csrf.Path("/"), csrf.FieldName("_csrf"), csrf.RequestHeader("X-CSRF-Token"))`.
- Export `func LoadKeyFromEnv() ([]byte, error)` that reads `SESSION_SECRET`, hex-decodes, validates `len == 32`. Return sentinel error `ErrCSRFKeyInvalid` for empty or wrong-length input. `cmd/web/main.go` calls this and `log.Fatal`s on error.
3. Create `backend/internal/web/ui/csrf_field.templ`:
- `package ui`
- `templ CSRFField(token string) { <input type="hidden" name="_csrf" value={ token }/> }`
- Run `templ generate` after writing.
4. Update `backend/templates/layout.templ`:
- Signature: `templ Layout(title string, user *auth.User, csrfToken string)`.
- Inside the logout `<form method="POST" action="/logout">`, FIRST child is `@ui.CSRFField(csrfToken)`. The button stays as the second child.
- Import path: align with how Button/Card/Badge are imported in existing templates.
5. Update `backend/templates/auth_login.templ`:
- `LoginPage(token string, errors map[string]string)` and `LoginFormFragment(token string, errors map[string]string)`.
- Each `<form>` has `@ui.CSRFField(token)` as the first child.
- `LoginPage` wraps `@Layout("Sign in", nil, token)`.
6. Update `backend/templates/auth_signup.templ`:
- `SignupPage(token string, errors map[string]string)` and `SignupFormFragment(token string, errors map[string]string)`.
- Same pattern: `@ui.CSRFField(token)` first child of every form; `@Layout("Sign up", nil, token)`.
7. Update `backend/templates/auth_form_errors.templ` if it owns a `<form>` shell: thread `token string` through and embed `@ui.CSRFField(token)`. If it only renders error markup, no change needed.
8. Update `backend/templates/index.templ`:
- `Index(user *auth.User, csrfToken string)` → wraps `@Layout("Xtablo", user, csrfToken)`.
9. Update `backend/internal/web/handlers.go`:
- `IndexHandler()` reads `auth.Authed(r.Context())` for user AND `csrf.Token(r)` for the token, then `templates.Index(user, csrf.Token(r)).Render(...)`.
10. Update `backend/internal/web/handlers_auth.go` — every page/fragment render call now passes `csrf.Token(r)`:
- `SignupPageHandler`: `templates.SignupPage(csrf.Token(r), nil).Render(...)`.
- `SignupPostHandler`: on validation error / duplicate-email branch, render `SignupPage` or `SignupFormFragment` with `csrf.Token(r)`. Success branch (303 redirect) does not render templ.
- `LoginPageHandler`: same pattern with `LoginPage`.
- `LoginPostHandler`: same pattern with `LoginPage` / `LoginFormFragment` on error branches.
- `LogoutHandler`: no templ render; unaffected except its form must now arrive with `_csrf` (handled in layout.templ).
- **Pitfall 1:** Audit every handler in this file. They MUST read form values via `r.PostFormValue(...)` only — never `io.ReadAll(r.Body)` or `json.NewDecoder(r.Body)`. Existing Plans 04/05 already use `r.PostFormValue` per their action sections; this is a regression guard.
11. Update `backend/internal/web/router.go`:
- After `r.Use(auth.ResolveSession(store, cookieName))` and BEFORE any route group: `r.Use(auth.Mount(env, csrfKey))`.
- The protected and public route groups stay as in Plan 06; csrf.Protect runs across both.
- Add a comment above each `r.Use` referencing D-24 locked order.
12. Update `backend/cmd/web/main.go`:
- Load `SESSION_SECRET` via `auth.LoadKeyFromEnv()`. On error, `log.Fatalf("invalid SESSION_SECRET: %v ; generate with `openssl rand -hex 32`", err)`.
- Pass `[]byte` key and `env` string into `NewRouter`.
- Update `NewRouter` signature accordingly (if not already accepting these params from Plan 04/06 — read router.go first, extend additively).
13. Update `backend/.env.example`:
- Add `SESSION_SECRET=` line with a comment: `# 32 random bytes hex-encoded — generate with: openssl rand -hex 32`.
- Add a placeholder or leave blank value; clearly NOT a real key.
14. Run `cd backend && templ generate && go build ./...` and fix any signature drift in call sites (Phase 1 + Plans 04..06 tests may reference old `Layout` / `Index` signatures).
Anti-pattern guards:
- `csrf.Mount` must be AFTER `auth.ResolveSession` (D-24, Pitfall 7).
- Every `<form method="POST">` in `.templ` files MUST contain `@ui.CSRFField(token)` as a literal first child (Pitfall 2). Acceptance grep enforces.
- Handlers MUST NOT touch `r.Body` directly (Pitfall 1).
- csrf key MUST come from env, not a compile-time constant (D-15).
- Cookie name for csrf is gorilla's default `_gorilla_csrf` — do NOT override; D-12 only governs the session cookie name.
cd backend && templ generate && go build ./... && TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/web/ ./internal/auth/ ./templates/ ./cmd/web/ -count=1 -run "TestCSRF|TestForms_ContainCSRFField|TestRouter_CSRFMountedAfterResolveSession|TestLoadCSRFKey|TestSignup|TestLogin|TestLogout|TestProtected|TestLayout" 2>&1 | tee /tmp/p07.log | tail -80 && ! grep -E "^FAIL|^--- FAIL" /tmp/p07.log
- `go.mod` contains `github.com/gorilla/csrf v1.7.3`.
- `backend/internal/auth/csrf.go` exists; `grep -c "func Mount" backend/internal/auth/csrf.go == 1`; `grep -c "csrf.Protect" backend/internal/auth/csrf.go == 1`; `grep -c "csrf.Secure" backend/internal/auth/csrf.go == 1`; `grep -c "csrf.SameSiteLaxMode" backend/internal/auth/csrf.go == 1`; `grep -c "LoadKeyFromEnv" backend/internal/auth/csrf.go == 1`.
- `backend/internal/web/ui/csrf_field.templ` exists; `grep -c "templ CSRFField" backend/internal/web/ui/csrf_field.templ == 1`; `grep -c 'name="_csrf"' backend/internal/web/ui/csrf_field.templ == 1`.
- Every templ file with a `` contains `@ui.CSRFField(`. Concretely, for each f in {layout.templ, auth_login.templ, auth_signup.templ}: `grep -v '^//' $f | awk '//' | grep -c 'ui.CSRFField\|CSRFField(' >= 1`.
- `backend/internal/web/router.go` has BOTH `auth.ResolveSession` and `auth.Mount` (or `csrf.Protect`) — and ResolveSession appears on an earlier line: `awk '/auth\.ResolveSession/{r=NR} /auth\.Mount|csrf\.Protect/{c=NR} END{exit !(r>0 && c>0 && r= 1`.
- `backend/.env.example` mentions `SESSION_SECRET`: `grep -c "SESSION_SECRET" backend/.env.example >= 1`.
- `cd backend && templ generate` exits 0.
- `cd backend && go build ./...` exits 0.
- `cd backend && TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web/ ./internal/auth/ ./cmd/web/ -run "TestCSRF|TestForms_ContainCSRFField|TestRouter_CSRFMountedAfterResolveSession|TestLoadCSRFKey" -count=1` exits 0 with all named tests PASS.
- Existing Plan 04/05/06 tests (TestSignup_*, TestLogin_*, TestLogout_*, TestProtected_*, TestLayout_*) still PASS — they must be updated to fetch a valid `_csrf` token (from GET response cookie + form-rendered hidden input) and include it on every POST. The plan executor MUST update those tests in-place rather than disable them.
- Grep gate forbidding bare body reads in auth handlers: `grep -E "io.ReadAll\\(r\\.Body|json\\.NewDecoder\\(r\\.Body" backend/internal/web/handlers_auth.go | grep -v '^#' | wc -l` returns 0.
AUTH-06 closed. Every state-changing route in Phase 2 (POST /signup, POST /login, POST /logout) requires a valid CSRF token; every form ships one by construction via @ui.CSRFField.
<threat_model>
Trust Boundaries
| Boundary |
Description |
| Cross-origin attacker → user's authenticated browser → POST |
gorilla/csrf double-submit + SameSite=Lax cuts both same-site and cross-site CSRF |
| Form submission → csrf.Protect → handler |
Middleware must run BEFORE handler; ordering is load-bearing (D-24, Pitfall 7) |
STRIDE Threat Register
| Threat ID |
Category |
Component |
Disposition |
Mitigation Plan |
| T-2-08 |
Tampering |
CSRF on POST /signup, POST /login, POST /logout |
mitigate |
gorilla/csrf v1.7.3 mounted on the chi stack with csrf.Secure(env != "dev") + csrf.SameSiteLaxMode; every templ form ships @ui.CSRFField(token) as its first child. Verified by TestCSRF_*MissingToken (403) and TestCSRF_*ValidToken (200/303). |
| T-2-08a |
Tampering |
Hidden CSRF input missing from a future form |
mitigate |
Acceptance grep gates require @ui.CSRFField inside every <form method="POST"> in templ files. Reusable ui.CSRFField component makes the correct path the path of least resistance. |
| T-2-08b |
Information disclosure |
csrf key stored in repo or compile-time constant |
mitigate |
auth.LoadKeyFromEnv() reads SESSION_SECRET from env; main.go fails fast on missing/short key. .env.example documents the value without a real key. |
| T-2-08c |
Spoofing |
Body consumed by csrf middleware → handler sees empty form |
mitigate |
Handlers exclusively use r.PostFormValue(...); acceptance grep forbids r.Body reads in handlers_auth.go. Verified by existing Plan 04/05 tests passing after the csrf mount lands. |
| T-2-08d |
Tampering |
csrf.Protect mounted before ResolveSession (wrong order) |
mitigate |
D-24 locked order; router.go acceptance assertion uses awk line-number compare to enforce ResolveSession appears before auth.Mount. |
| </threat_model> |
|
|
|
|
- TestCSRF_LoginMissingToken / TestCSRF_LoginValidToken pass.
- TestCSRF_SignupMissingToken / TestCSRF_SignupValidToken pass.
- TestCSRF_LogoutMissingToken / TestCSRF_LogoutValidToken pass.
- TestCSRF_HeaderFallback passes (X-CSRF-Token header works for future HTMX hx-headers usage).
- TestForms_ContainCSRFField passes — every form-rendering templ contains the hidden field.
- TestRouter_CSRFMountedAfterResolveSession passes — middleware order is correct.
- TestLoadCSRFKey_Missing / TestLoadCSRFKey_WrongLength / TestLoadCSRFKey_Valid pass.
- All Plan 04/05/06 tests still pass after being updated to acquire and submit a valid `_csrf` token.
- Manual browser walkthrough: sign up → /, click Log out → /login, log back in. Every form submission has a `_csrf` hidden input visible in DevTools.
<success_criteria>
- AUTH-06 closed: all state-changing POSTs require a valid CSRF token; missing/invalid → 403.
- The reusable
ui.CSRFField component is the canonical way to embed CSRF in future templ forms.
- SESSION_SECRET is documented and loaded from env; missing key fails the binary fast at startup.
- Middleware order (
ResolveSession → csrf.Protect → route groups) matches D-24 exactly.
</success_criteria>
Create `.planning/phases/02-authentication/02-07-SUMMARY.md` recording:
- Final list of templ files audited and the form-by-form CSRF-field placement
- Confirmation that all Plan 04/05/06 tests were updated (or already were CSRF-aware)
- Any decisions on token surfacing in HTMX hx-headers (left for a future polish pass; currently the form field is the canonical path)
- Snapshot of the final router middleware chain (single-source-of-truth diff base for Phase 3 / future security hardening)