Per-session CSRF tokens on state-changing form POSTs (M-6) #109
No reviewers
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
qwc-open/asiakirjat!109
Loading…
Reference in a new issue
No description provided.
Delete branch "feature/csrf-protection"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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.What's wired
internal/auth/csrf.goGenerateCSRFSecret,ComputeCSRFToken,ValidateCSRFToken(constant-time)internal/auth/session.goSessionManager.CSRFToken(r)/VerifyCSRF(r, presented); newcsrfSecretparam onNewSessionManagermain.gointernal/handler/csrf.goh.verifyCSRF(r)(header → PostForm → query);h.requireCSRFmiddlewareinternal/handler/handler.gorendersignature takes*http.Requestand auto-injectsCSRFTokeninto the data map (35 call sites updated). 25 POST routes wrapped inrequireCSRFinternal/handler/upload.goverifyCSRFafterParseMultipartFormso the body limit isn't reduced to the default 32 MBpartials/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 theAuthorizationheader 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)csrf_tokeninputcsrf_tokenfield from a form and resubmit → 403🤖 Generated with Claude Code
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>