Per-session CSRF tokens on state-changing form POSTs (M-6) #109

Merged
qwc merged 1 commit from feature/csrf-protection into main 2026-05-18 21:50:59 +02:00
Owner

Summary

Workstream item #14. Audit finding M-6 (Medium) — defense-in-depth against CSRF.

SameSite=Lax cookies are the only line of defense today. The audit flagged this as a single point of failure: future SameSite=None (e.g. cross-subdomain), lenient lax handling on sibling subdomains, or any GET-with-side-effects regression would re-open CSRF. This PR adds the standard per-form-POST token check on top.

Implementation: double-submit with HMAC-derived tokens

  • No new state needed. Token = hex(HMAC-SHA256(secret, sessionID)). Server holds the secret in memory (32 random bytes generated at startup), the session ID lives in the HttpOnly+Lax session cookie. Cross-origin attackers can read neither.
  • No migration. Token is recomputable from the session cookie + in-memory secret.
  • Restart cost. When the process restarts the secret rotates and in-flight form pages stop working — the user refreshes once and continues. Same UX as a session expiring; documented in the code.

What's wired

Layer Change
internal/auth/csrf.go GenerateCSRFSecret, ComputeCSRFToken, ValidateCSRFToken (constant-time)
internal/auth/session.go SessionManager.CSRFToken(r) / VerifyCSRF(r, presented); new csrfSecret param on NewSessionManager
main.go Secret generated at startup and passed in
internal/handler/csrf.go h.verifyCSRF(r) (header → PostForm → query); h.requireCSRF middleware
internal/handler/handler.go render signature takes *http.Request and auto-injects CSRFToken into the data map (35 call sites updated). 25 POST routes wrapped in requireCSRF
internal/handler/upload.go Multipart upload handler calls verifyCSRF after ParseMultipartForm so the body limit isn't reduced to the default 32 MB
Templates New partials/csrf_field.html; ~27 forms across 9 templates updated to {{template "csrf_field" $.CSRFToken}}

Intentionally exempt:

  • POST /login (no session yet to bind a token to)
  • /api/* (bearer-only; not vulnerable to CSRF since attacker can't set the Authorization header from a cross-origin context)

Tests

  • TestPOSTWithoutCSRFTokenRejected — same admin form, two requests: missing token → 403, valid token → 303.
  • TestPOSTWithForgedCSRFTokenRejected — token computed with the wrong HMAC secret → 403. Proves the HMAC is actually doing work; an attacker who somehow guessed the session ID still cannot forge.
  • csrfTokenFor(t, app, cookies) test helper derives the expected token from the test handler's secret; bulk-injected into all existing tests that send state-changing POSTs.

Test plan

  • go test ./... passes (all packages)
  • New CSRF tests confirm the protection fires
  • Existing tests confirm the protection doesn't over-block
  • Manual: load any admin form, inspect HTML for the hidden csrf_token input
  • Manual: submit an admin form normally → works
  • Manual: with the dev tools, strip the csrf_token field from a form and resubmit → 403
  • Manual: upload a file → still works (multipart path)

🤖 Generated with Claude Code

## Summary Workstream item #14. Audit finding **M-6 (Medium)** — defense-in-depth against CSRF. SameSite=Lax cookies are the only line of defense today. The audit flagged this as a single point of failure: future SameSite=None (e.g. cross-subdomain), lenient lax handling on sibling subdomains, or any GET-with-side-effects regression would re-open CSRF. This PR adds the standard per-form-POST token check on top. ## Implementation: double-submit with HMAC-derived tokens - **No new state needed.** Token = `hex(HMAC-SHA256(secret, sessionID))`. Server holds the secret in memory (32 random bytes generated at startup), the session ID lives in the HttpOnly+Lax session cookie. Cross-origin attackers can read neither. - **No migration.** Token is recomputable from the session cookie + in-memory secret. - **Restart cost.** When the process restarts the secret rotates and in-flight form pages stop working — the user refreshes once and continues. Same UX as a session expiring; documented in the code. ## What's wired | Layer | Change | | - | - | | `internal/auth/csrf.go` | `GenerateCSRFSecret`, `ComputeCSRFToken`, `ValidateCSRFToken` (constant-time) | | `internal/auth/session.go` | `SessionManager.CSRFToken(r)` / `VerifyCSRF(r, presented)`; new `csrfSecret` param on `NewSessionManager` | | `main.go` | Secret generated at startup and passed in | | `internal/handler/csrf.go` | `h.verifyCSRF(r)` (header → PostForm → query); `h.requireCSRF` middleware | | `internal/handler/handler.go` | `render` signature takes `*http.Request` and auto-injects `CSRFToken` into the data map (35 call sites updated). 25 POST routes wrapped in `requireCSRF` | | `internal/handler/upload.go` | Multipart upload handler calls `verifyCSRF` **after** `ParseMultipartForm` so the body limit isn't reduced to the default 32 MB | | Templates | New `partials/csrf_field.html`; ~27 forms across 9 templates updated to `{{template "csrf_field" $.CSRFToken}}` | Intentionally exempt: - `POST /login` (no session yet to bind a token to) - `/api/*` (bearer-only; not vulnerable to CSRF since attacker can't set the `Authorization` header from a cross-origin context) ## Tests - `TestPOSTWithoutCSRFTokenRejected` — same admin form, two requests: missing token → 403, valid token → 303. - `TestPOSTWithForgedCSRFTokenRejected` — token computed with the wrong HMAC secret → 403. Proves the HMAC is actually doing work; an attacker who somehow guessed the session ID still cannot forge. - `csrfTokenFor(t, app, cookies)` test helper derives the expected token from the test handler's secret; bulk-injected into all existing tests that send state-changing POSTs. ## Test plan - [x] `go test ./...` passes (all packages) - [x] New CSRF tests confirm the protection fires - [x] Existing tests confirm the protection doesn't over-block - [ ] Manual: load any admin form, inspect HTML for the hidden `csrf_token` input - [ ] Manual: submit an admin form normally → works - [ ] Manual: with the dev tools, strip the `csrf_token` field from a form and resubmit → 403 - [ ] Manual: upload a file → still works (multipart path) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Add per-session CSRF tokens on state-changing form POSTs (M-6)
All checks were successful
CI / test (pull_request) Successful in 1m25s
CI / build (pull_request) Successful in 56s
CI / docker (pull_request) Has been skipped
fd9870367a
Audit finding M-6. SameSite=Lax cookies alone protect against cross-site
form POSTs today, but the audit flagged it as the single point of
failure: a future SameSite=None, lenient lax handling on sibling
subdomains, or any GET-with-side-effects regression would re-open CSRF.
Adds defense-in-depth.

Implementation — double-submit with HMAC-derived tokens:

  internal/auth/csrf.go
    - GenerateCSRFSecret() 32 random bytes
    - ComputeCSRFToken(secret, sessionID) = hex(HMAC-SHA256(secret, sid))
    - ValidateCSRFToken with constant-time compare

  internal/auth/session.go
    - SessionManager carries a server-process csrfSecret (generated in
      main at startup; rotates on restart)
    - CSRFToken(*http.Request) and VerifyCSRF(*http.Request, presented)
      pull the session ID from the cookie

  internal/handler/csrf.go
    - h.verifyCSRF(r) prefers X-CSRF-Token header, falls back to
      r.PostForm/FormValue, then query string
    - h.requireCSRF middleware wraps non-multipart POST handlers
    - For multipart upload (web): handler calls verifyCSRF AFTER
      ParseMultipartForm so PostForm is populated under its own size cap
    - /login (no session yet) and /api/* (bearer auth) intentionally
      exempt

  internal/handler/handler.go
    - render() now takes *http.Request and auto-injects CSRFToken into
      the data map; all 35 call sites updated to pass r
    - 25 state-changing POST routes wrapped in requireCSRF

  internal/templates/partials/csrf_field.html — partial renders the
  hidden input; called from every form template as
  `{{template "csrf_field" $.CSRFToken}}` so the partial works inside
  range/with scopes where . isn't the root data

  Inserted into all ~27 forms across version_list, admin_projects,
  upload, admin_global_access, admin_robots, admin_project_edit,
  project_tokens, admin_groups, profile, admin_users templates

Tests:
  - New TestPOSTWithoutCSRFTokenRejected — same form sent without and
    with token; 403 then 303
  - New TestPOSTWithForgedCSRFTokenRejected — token computed with a
    wrong HMAC secret must not validate; proves the HMAC is doing work
  - csrfTokenFor test helper derives the expected token from the test
    handler's secret; bulk-injected into every existing test that sends
    a state-changing POST

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
qwc merged commit 4dad258b38 into main 2026-05-18 21:50:59 +02:00
qwc deleted branch feature/csrf-protection 2026-05-18 21:50:59 +02:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
qwc-open/asiakirjat!109
No description provided.