···2602603. **Public by default** - Social interactions are public records, readable by anyone
2612614. **Portable identity** - Users can switch PDS and keep their social graph
262262263263+## Deployment Notes
264264+265265+### CSS Cache Busting
266266+267267+When making CSS/style changes, bump the version query parameter in `templates/layout.tmpl`:
268268+269269+```html
270270+<link rel="stylesheet" href="/static/css/output.css?v=0.1.3" />
271271+```
272272+273273+Cloudflare caches static assets, so incrementing the version ensures users get the updated styles.
274274+263275## Known Issues / TODOs
264276265277See todo list in conversation for tracked issues. Key areas:
+1309
docs/csrf-implementation-plan.md
···11+# CSRF Protection Implementation Plan
22+33+This document provides a comprehensive plan for implementing CSRF (Cross-Site Request Forgery) protection in Arabica.
44+55+## Table of Contents
66+77+1. [Overview](#overview)
88+2. [Architecture](#architecture)
99+3. [Implementation Steps](#implementation-steps)
1010+4. [Files to Modify](#files-to-modify)
1111+5. [Testing Plan](#testing-plan)
1212+6. [Rollback Plan](#rollback-plan)
1313+1414+---
1515+1616+## Overview
1717+1818+### What We're Protecting
1919+2020+All state-changing endpoints (POST, PUT, DELETE) that modify user data:
2121+2222+| Endpoint | Method | Purpose |
2323+|----------|--------|---------|
2424+| `/auth/login` | POST | User login |
2525+| `/logout` | POST | User logout |
2626+| `/brews` | POST | Create brew |
2727+| `/brews/{id}` | PUT | Update brew |
2828+| `/brews/{id}` | DELETE | Delete brew |
2929+| `/api/beans` | POST | Create bean |
3030+| `/api/beans/{id}` | PUT | Update bean |
3131+| `/api/beans/{id}` | DELETE | Delete bean |
3232+| `/api/roasters` | POST | Create roaster |
3333+| `/api/roasters/{id}` | PUT | Update roaster |
3434+| `/api/roasters/{id}` | DELETE | Delete roaster |
3535+| `/api/grinders` | POST | Create grinder |
3636+| `/api/grinders/{id}` | PUT | Update grinder |
3737+| `/api/grinders/{id}` | DELETE | Delete grinder |
3838+| `/api/brewers` | POST | Create brewer |
3939+| `/api/brewers/{id}` | PUT | Update brewer |
4040+| `/api/brewers/{id}` | DELETE | Delete brewer |
4141+4242+### Exempt Endpoints
4343+4444+| Endpoint | Reason |
4545+|----------|--------|
4646+| `GET /oauth/callback` | Uses OAuth `state` parameter for CSRF protection |
4747+| `GET /*` | Read-only, no state changes |
4848+4949+---
5050+5151+## Architecture
5252+5353+### Token Strategy: Double Submit Cookie
5454+5555+We'll use the **Double Submit Cookie** pattern because:
5656+1. Stateless - No server-side token storage needed
5757+2. Works well with HTMX and JavaScript fetch calls
5858+3. Simple to implement
5959+6060+**How it works:**
6161+6262+```
6363+1. Server generates random CSRF token
6464+2. Token sent to browser in TWO ways:
6565+ a) As a cookie: `csrf_token=abc123` (HttpOnly=false, so JS can read it)
6666+ b) Embedded in page (meta tag or JS variable)
6767+6868+3. Browser must send token back in TWO ways:
6969+ a) Automatically via cookie (browser does this)
7070+ b) Manually via header `X-CSRF-Token: abc123` or form field
7171+7272+4. Server validates: cookie token == header/form token
7373+```
7474+7575+**Why this works:**
7676+- Attacker on `evil.com` can trigger requests with cookies (browser sends automatically)
7777+- But attacker CANNOT read the cookie value (Same-Origin Policy)
7878+- So attacker cannot include matching header/form value
7979+- Request is rejected
8080+8181+### Token Lifecycle
8282+8383+```
8484+┌─────────────────────────────────────────────────────────────────┐
8585+│ Token Generation │
8686+├─────────────────────────────────────────────────────────────────┤
8787+│ When: First request from a browser (no existing CSRF cookie) │
8888+│ How: Generate 32-byte random token using crypto/rand │
8989+│ Set: Cookie `csrf_token` with value │
9090+│ - Path: / │
9191+│ - HttpOnly: false (JS needs to read it) │
9292+│ - Secure: true (in production) │
9393+│ - SameSite: Strict │
9494+│ - MaxAge: 86400 (24 hours, shorter than session) │
9595+└─────────────────────────────────────────────────────────────────┘
9696+9797+┌─────────────────────────────────────────────────────────────────┐
9898+│ Token Validation │
9999+├─────────────────────────────────────────────────────────────────┤
100100+│ When: Any POST, PUT, DELETE, PATCH request │
101101+│ Steps: │
102102+│ 1. Read token from cookie │
103103+│ 2. Read token from header (X-CSRF-Token) OR form (csrf_token) │
104104+│ 3. Compare using constant-time comparison │
105105+│ 4. If mismatch or missing: HTTP 403 Forbidden │
106106+│ 5. If match: Continue to handler │
107107+└─────────────────────────────────────────────────────────────────┘
108108+```
109109+110110+---
111111+112112+## Implementation Steps
113113+114114+### Phase 1: Backend Middleware (Go)
115115+116116+#### 1.1 Create CSRF Middleware
117117+118118+**File:** `internal/middleware/csrf.go`
119119+120120+```go
121121+package middleware
122122+123123+import (
124124+ "crypto/rand"
125125+ "crypto/subtle"
126126+ "encoding/base64"
127127+ "net/http"
128128+ "strings"
129129+)
130130+131131+const (
132132+ CSRFTokenCookieName = "csrf_token"
133133+ CSRFTokenHeaderName = "X-CSRF-Token"
134134+ CSRFTokenFormField = "csrf_token"
135135+ CSRFTokenLength = 32
136136+)
137137+138138+// CSRFConfig holds CSRF middleware configuration
139139+type CSRFConfig struct {
140140+ // SecureCookie sets the Secure flag on the CSRF cookie
141141+ SecureCookie bool
142142+143143+ // ExemptPaths are paths that skip CSRF validation (e.g., OAuth callback)
144144+ ExemptPaths []string
145145+146146+ // ExemptMethods are HTTP methods that skip CSRF validation
147147+ // Default: GET, HEAD, OPTIONS, TRACE
148148+ ExemptMethods []string
149149+}
150150+151151+// DefaultCSRFConfig returns default configuration
152152+func DefaultCSRFConfig() *CSRFConfig {
153153+ return &CSRFConfig{
154154+ SecureCookie: false,
155155+ ExemptPaths: []string{"/oauth/callback"},
156156+ ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"},
157157+ }
158158+}
159159+160160+// generateToken creates a cryptographically secure random token
161161+func generateToken() (string, error) {
162162+ bytes := make([]byte, CSRFTokenLength)
163163+ if _, err := rand.Read(bytes); err != nil {
164164+ return "", err
165165+ }
166166+ return base64.URLEncoding.EncodeToString(bytes), nil
167167+}
168168+169169+// CSRFMiddleware provides CSRF protection using double-submit cookie pattern
170170+func CSRFMiddleware(config *CSRFConfig) func(http.Handler) http.Handler {
171171+ if config == nil {
172172+ config = DefaultCSRFConfig()
173173+ }
174174+175175+ // Build exempt method set for fast lookup
176176+ exemptMethods := make(map[string]bool)
177177+ for _, m := range config.ExemptMethods {
178178+ exemptMethods[strings.ToUpper(m)] = true
179179+ }
180180+181181+ return func(next http.Handler) http.Handler {
182182+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
183183+ // Get or generate CSRF token
184184+ var token string
185185+ cookie, err := r.Cookie(CSRFTokenCookieName)
186186+ if err == nil && cookie.Value != "" {
187187+ token = cookie.Value
188188+ } else {
189189+ // Generate new token
190190+ token, err = generateToken()
191191+ if err != nil {
192192+ http.Error(w, "Internal server error", http.StatusInternalServerError)
193193+ return
194194+ }
195195+196196+ // Set cookie
197197+ http.SetCookie(w, &http.Cookie{
198198+ Name: CSRFTokenCookieName,
199199+ Value: token,
200200+ Path: "/",
201201+ HttpOnly: false, // JS needs to read this
202202+ Secure: config.SecureCookie,
203203+ SameSite: http.SameSiteStrictMode,
204204+ MaxAge: 86400, // 24 hours
205205+ })
206206+ }
207207+208208+ // Store token in response header for JS to access
209209+ // This is an alternative to reading from cookie
210210+ w.Header().Set("X-CSRF-Token", token)
211211+212212+ // Check if method requires validation
213213+ if exemptMethods[r.Method] {
214214+ next.ServeHTTP(w, r)
215215+ return
216216+ }
217217+218218+ // Check if path is exempt
219219+ for _, path := range config.ExemptPaths {
220220+ if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path) {
221221+ next.ServeHTTP(w, r)
222222+ return
223223+ }
224224+ }
225225+226226+ // Validate CSRF token
227227+ // Try header first (JavaScript requests)
228228+ submittedToken := r.Header.Get(CSRFTokenHeaderName)
229229+230230+ // Fall back to form field (traditional forms)
231231+ if submittedToken == "" {
232232+ submittedToken = r.FormValue(CSRFTokenFormField)
233233+ }
234234+235235+ // Validate token
236236+ if submittedToken == "" {
237237+ http.Error(w, "CSRF token missing", http.StatusForbidden)
238238+ return
239239+ }
240240+241241+ // Constant-time comparison to prevent timing attacks
242242+ if subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 {
243243+ http.Error(w, "CSRF token invalid", http.StatusForbidden)
244244+ return
245245+ }
246246+247247+ next.ServeHTTP(w, r)
248248+ })
249249+ }
250250+}
251251+252252+// GetCSRFToken extracts the CSRF token from request cookies
253253+// Used by template rendering to include token in forms
254254+func GetCSRFToken(r *http.Request) string {
255255+ cookie, err := r.Cookie(CSRFTokenCookieName)
256256+ if err != nil {
257257+ return ""
258258+ }
259259+ return cookie.Value
260260+}
261261+```
262262+263263+#### 1.2 Update Routing
264264+265265+**File:** `internal/routing/routing.go`
266266+267267+Add CSRF middleware to the chain:
268268+269269+```go
270270+// Apply middleware in order (outermost first, innermost last)
271271+var handler http.Handler = mux
272272+273273+// 1. Limit request body size (innermost - runs first on request)
274274+handler = middleware.LimitBodyMiddleware(handler)
275275+276276+// 2. Apply CSRF protection (before auth, validates tokens)
277277+csrfConfig := &middleware.CSRFConfig{
278278+ SecureCookie: cfg.SecureCookies, // Pass from config
279279+ ExemptPaths: []string{"/oauth/callback"},
280280+}
281281+handler = middleware.CSRFMiddleware(csrfConfig)(handler)
282282+283283+// 3. Apply OAuth middleware to add auth context
284284+handler = cfg.OAuthManager.AuthMiddleware(handler)
285285+286286+// ... rest of middleware
287287+```
288288+289289+#### 1.3 Update BFF/Render to Include Token
290290+291291+**File:** `internal/bff/render.go`
292292+293293+Add CSRF token to PageData:
294294+295295+```go
296296+type PageData struct {
297297+ Title string
298298+ IsAuthenticated bool
299299+ UserDID string
300300+ CSRFToken string // ADD THIS
301301+ // ... other fields
302302+}
303303+304304+// Update all Render* functions to accept and include CSRF token
305305+func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem, csrfToken string) error {
306306+ data := &HomePageData{
307307+ PageData: PageData{
308308+ Title: "Arabica - Coffee Brew Tracker",
309309+ IsAuthenticated: isAuthenticated,
310310+ UserDID: userDID,
311311+ CSRFToken: csrfToken,
312312+ },
313313+ // ...
314314+ }
315315+ // ...
316316+}
317317+```
318318+319319+#### 1.4 Update Handlers to Pass Token
320320+321321+**File:** `internal/handlers/handlers.go`
322322+323323+Update handlers to pass CSRF token to templates:
324324+325325+```go
326326+func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) {
327327+ csrfToken := middleware.GetCSRFToken(r)
328328+ // ... pass csrfToken to render function
329329+}
330330+```
331331+332332+### Phase 2: Frontend Changes
333333+334334+#### 2.1 Update Templates with Hidden Fields
335335+336336+**File:** `templates/home.tmpl`
337337+338338+Add CSRF token to login form:
339339+340340+```html
341341+<form method="POST" action="/auth/login" class="max-w-md mx-auto">
342342+ <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
343343+ <!-- rest of form -->
344344+</form>
345345+```
346346+347347+Add CSRF token to logout form:
348348+349349+```html
350350+<form action="/logout" method="POST" class="inline-block">
351351+ <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
352352+ <button type="submit">Logout</button>
353353+</form>
354354+```
355355+356356+**File:** `templates/brew_form.tmpl`
357357+358358+Add CSRF token and configure HTMX:
359359+360360+```html
361361+<form id="brew-form"
362362+ {{if .Brew}}
363363+ hx-put="/brews/{{.Brew.RKey}}"
364364+ {{else}}
365365+ hx-post="/brews"
366366+ {{end}}
367367+ hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}'
368368+ hx-target="body"
369369+ hx-swap="none">
370370+371371+ <!-- form fields -->
372372+</form>
373373+```
374374+375375+**File:** `templates/partials/brew_list_content.tmpl`
376376+377377+Add CSRF header to delete button:
378378+379379+```html
380380+<button hx-delete="/brews/{{.RKey}}"
381381+ hx-headers='{"X-CSRF-Token": "{{$.CSRFToken}}"}'
382382+ hx-confirm="Are you sure?"
383383+ hx-target="closest .brew-card"
384384+ hx-swap="outerHTML">
385385+ Delete
386386+</button>
387387+```
388388+389389+#### 2.2 Update JavaScript Files
390390+391391+**File:** `web/static/js/csrf.js` (NEW)
392392+393393+Create helper for CSRF token:
394394+395395+```javascript
396396+/**
397397+ * CSRF Token Helper
398398+ *
399399+ * Usage:
400400+ * import { getCSRFToken, csrfFetch } from './csrf.js';
401401+ *
402402+ * // Manual header
403403+ * fetch('/api/beans', {
404404+ * method: 'POST',
405405+ * headers: { 'X-CSRF-Token': getCSRFToken() }
406406+ * });
407407+ *
408408+ * // Or use wrapper
409409+ * csrfFetch('/api/beans', { method: 'POST', body: data });
410410+ */
411411+412412+/**
413413+ * Get CSRF token from cookie
414414+ */
415415+export function getCSRFToken() {
416416+ const name = 'csrf_token=';
417417+ const decodedCookie = decodeURIComponent(document.cookie);
418418+ const cookies = decodedCookie.split(';');
419419+420420+ for (let cookie of cookies) {
421421+ cookie = cookie.trim();
422422+ if (cookie.indexOf(name) === 0) {
423423+ return cookie.substring(name.length);
424424+ }
425425+ }
426426+ return '';
427427+}
428428+429429+/**
430430+ * Fetch wrapper that automatically includes CSRF token
431431+ */
432432+export async function csrfFetch(url, options = {}) {
433433+ const headers = options.headers || {};
434434+435435+ // Only add CSRF token for state-changing methods
436436+ const method = (options.method || 'GET').toUpperCase();
437437+ if (!['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method)) {
438438+ headers['X-CSRF-Token'] = getCSRFToken();
439439+ }
440440+441441+ return fetch(url, { ...options, headers });
442442+}
443443+444444+// Configure HTMX to include CSRF token on all requests
445445+document.addEventListener('DOMContentLoaded', function() {
446446+ document.body.addEventListener('htmx:configRequest', function(event) {
447447+ // Add CSRF token header to all HTMX requests
448448+ event.detail.headers['X-CSRF-Token'] = getCSRFToken();
449449+ });
450450+});
451451+```
452452+453453+**File:** `web/static/js/manage-page.js`
454454+455455+Update all fetch calls:
456456+457457+```javascript
458458+// At top of file
459459+import { getCSRFToken } from './csrf.js';
460460+461461+// Update all fetch calls to include header
462462+async function saveBean(data, rkey = null) {
463463+ const url = rkey ? `/api/beans/${rkey}` : '/api/beans';
464464+ const method = rkey ? 'PUT' : 'POST';
465465+466466+ const response = await fetch(url, {
467467+ method: method,
468468+ headers: {
469469+ 'Content-Type': 'application/json',
470470+ 'X-CSRF-Token': getCSRFToken() // ADD THIS
471471+ },
472472+ body: JSON.stringify(data)
473473+ });
474474+ // ...
475475+}
476476+477477+async function deleteBean(rkey) {
478478+ const response = await fetch(`/api/beans/${rkey}`, {
479479+ method: 'DELETE',
480480+ headers: {
481481+ 'X-CSRF-Token': getCSRFToken() // ADD THIS
482482+ }
483483+ });
484484+ // ...
485485+}
486486+487487+// Apply same pattern to all other CRUD functions:
488488+// - saveRoaster, deleteRoaster
489489+// - saveGrinder, deleteGrinder
490490+// - saveBrewer, deleteBrewer
491491+```
492492+493493+**File:** `web/static/js/brew-form.js`
494494+495495+Update inline creation fetch calls:
496496+497497+```javascript
498498+import { getCSRFToken } from './csrf.js';
499499+500500+// Update bean creation
501501+async function createInlineBean() {
502502+ const response = await fetch('/api/beans', {
503503+ method: 'POST',
504504+ headers: {
505505+ 'Content-Type': 'application/json',
506506+ 'X-CSRF-Token': getCSRFToken() // ADD THIS
507507+ },
508508+ body: JSON.stringify(beanData)
509509+ });
510510+ // ...
511511+}
512512+513513+// Apply to grinder and brewer creation too
514514+```
515515+516516+#### 2.3 Update Layout Template
517517+518518+**File:** `templates/layout.tmpl`
519519+520520+Include CSRF script globally:
521521+522522+```html
523523+<head>
524524+ <!-- ... -->
525525+ <script type="module" src="/static/js/csrf.js"></script>
526526+</head>
527527+```
528528+529529+Or add meta tag for non-module scripts:
530530+531531+```html
532532+<head>
533533+ <meta name="csrf-token" content="{{.CSRFToken}}">
534534+</head>
535535+```
536536+537537+---
538538+539539+## Files to Modify
540540+541541+### New Files
542542+543543+| File | Purpose |
544544+|------|---------|
545545+| `internal/middleware/csrf.go` | CSRF middleware implementation |
546546+| `internal/middleware/csrf_test.go` | Unit tests for CSRF middleware |
547547+| `web/static/js/csrf.js` | JavaScript CSRF helper |
548548+| `test/csrf/csrf_test.go` | Integration tests |
549549+| `test/csrf/server_test.go` | Test server setup |
550550+551551+### Modified Files
552552+553553+| File | Changes |
554554+|------|---------|
555555+| `internal/routing/routing.go` | Add CSRF middleware to chain |
556556+| `internal/bff/render.go` | Add CSRFToken to PageData and all render functions |
557557+| `internal/handlers/handlers.go` | Pass CSRF token to render functions |
558558+| `internal/handlers/auth.go` | Pass CSRF token to home render |
559559+| `templates/layout.tmpl` | Include CSRF meta tag or script |
560560+| `templates/home.tmpl` | Add hidden fields to login/logout forms |
561561+| `templates/brew_form.tmpl` | Add HTMX headers with CSRF token |
562562+| `templates/partials/brew_list_content.tmpl` | Add CSRF header to delete buttons |
563563+| `templates/partials/manage_content.tmpl` | Ensure CSRF token available |
564564+| `web/static/js/manage-page.js` | Add CSRF header to all fetch calls |
565565+| `web/static/js/brew-form.js` | Add CSRF header to inline creation |
566566+| `web/static/js/data-cache.js` | Check if any POST calls need CSRF |
567567+568568+---
569569+570570+## Testing Plan
571571+572572+### Unit Tests
573573+574574+**File:** `internal/middleware/csrf_test.go`
575575+576576+```go
577577+package middleware
578578+579579+import (
580580+ "net/http"
581581+ "net/http/httptest"
582582+ "strings"
583583+ "testing"
584584+)
585585+586586+func TestCSRFTokenGeneration(t *testing.T) {
587587+ // Test that tokens are generated correctly
588588+ token, err := generateToken()
589589+ if err != nil {
590590+ t.Fatalf("Failed to generate token: %v", err)
591591+ }
592592+ if len(token) == 0 {
593593+ t.Error("Generated token is empty")
594594+ }
595595+596596+ // Test uniqueness
597597+ token2, _ := generateToken()
598598+ if token == token2 {
599599+ t.Error("Tokens should be unique")
600600+ }
601601+}
602602+603603+func TestCSRFMiddleware_SetsTokenCookie(t *testing.T) {
604604+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
605605+ w.WriteHeader(http.StatusOK)
606606+ }))
607607+608608+ req := httptest.NewRequest("GET", "/", nil)
609609+ rec := httptest.NewRecorder()
610610+611611+ handler.ServeHTTP(rec, req)
612612+613613+ // Check cookie is set
614614+ cookies := rec.Result().Cookies()
615615+ var csrfCookie *http.Cookie
616616+ for _, c := range cookies {
617617+ if c.Name == CSRFTokenCookieName {
618618+ csrfCookie = c
619619+ break
620620+ }
621621+ }
622622+623623+ if csrfCookie == nil {
624624+ t.Error("CSRF cookie not set")
625625+ }
626626+ if csrfCookie.Value == "" {
627627+ t.Error("CSRF cookie value is empty")
628628+ }
629629+}
630630+631631+func TestCSRFMiddleware_GETRequestsExempt(t *testing.T) {
632632+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
633633+ w.WriteHeader(http.StatusOK)
634634+ }))
635635+636636+ // GET without token should succeed
637637+ req := httptest.NewRequest("GET", "/some-page", nil)
638638+ rec := httptest.NewRecorder()
639639+640640+ handler.ServeHTTP(rec, req)
641641+642642+ if rec.Code != http.StatusOK {
643643+ t.Errorf("GET request should succeed, got status %d", rec.Code)
644644+ }
645645+}
646646+647647+func TestCSRFMiddleware_POSTWithoutToken_Fails(t *testing.T) {
648648+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
649649+ w.WriteHeader(http.StatusOK)
650650+ }))
651651+652652+ // POST without token should fail
653653+ req := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}"))
654654+ req.Header.Set("Content-Type", "application/json")
655655+ rec := httptest.NewRecorder()
656656+657657+ handler.ServeHTTP(rec, req)
658658+659659+ if rec.Code != http.StatusForbidden {
660660+ t.Errorf("POST without CSRF token should return 403, got %d", rec.Code)
661661+ }
662662+}
663663+664664+func TestCSRFMiddleware_POSTWithValidToken_Succeeds(t *testing.T) {
665665+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
666666+ w.WriteHeader(http.StatusOK)
667667+ }))
668668+669669+ // First, get a token via GET
670670+ getReq := httptest.NewRequest("GET", "/", nil)
671671+ getRec := httptest.NewRecorder()
672672+ handler.ServeHTTP(getRec, getReq)
673673+674674+ var token string
675675+ for _, c := range getRec.Result().Cookies() {
676676+ if c.Name == CSRFTokenCookieName {
677677+ token = c.Value
678678+ break
679679+ }
680680+ }
681681+682682+ // Now POST with valid token
683683+ postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}"))
684684+ postReq.Header.Set("Content-Type", "application/json")
685685+ postReq.Header.Set(CSRFTokenHeaderName, token)
686686+ postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
687687+ postRec := httptest.NewRecorder()
688688+689689+ handler.ServeHTTP(postRec, postReq)
690690+691691+ if postRec.Code != http.StatusOK {
692692+ t.Errorf("POST with valid CSRF token should succeed, got %d", postRec.Code)
693693+ }
694694+}
695695+696696+func TestCSRFMiddleware_POSTWithInvalidToken_Fails(t *testing.T) {
697697+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
698698+ w.WriteHeader(http.StatusOK)
699699+ }))
700700+701701+ // First, get a token
702702+ getReq := httptest.NewRequest("GET", "/", nil)
703703+ getRec := httptest.NewRecorder()
704704+ handler.ServeHTTP(getRec, getReq)
705705+706706+ var token string
707707+ for _, c := range getRec.Result().Cookies() {
708708+ if c.Name == CSRFTokenCookieName {
709709+ token = c.Value
710710+ break
711711+ }
712712+ }
713713+714714+ // POST with wrong token
715715+ postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}"))
716716+ postReq.Header.Set("Content-Type", "application/json")
717717+ postReq.Header.Set(CSRFTokenHeaderName, "wrong-token")
718718+ postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
719719+ postRec := httptest.NewRecorder()
720720+721721+ handler.ServeHTTP(postRec, postReq)
722722+723723+ if postRec.Code != http.StatusForbidden {
724724+ t.Errorf("POST with invalid CSRF token should return 403, got %d", postRec.Code)
725725+ }
726726+}
727727+728728+func TestCSRFMiddleware_ExemptPath(t *testing.T) {
729729+ config := &CSRFConfig{
730730+ ExemptPaths: []string{"/oauth/callback"},
731731+ }
732732+ handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
733733+ w.WriteHeader(http.StatusOK)
734734+ }))
735735+736736+ // POST to exempt path without token should succeed
737737+ req := httptest.NewRequest("POST", "/oauth/callback", nil)
738738+ rec := httptest.NewRecorder()
739739+740740+ handler.ServeHTTP(rec, req)
741741+742742+ if rec.Code != http.StatusOK {
743743+ t.Errorf("Exempt path should succeed without token, got %d", rec.Code)
744744+ }
745745+}
746746+747747+func TestCSRFMiddleware_FormField(t *testing.T) {
748748+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
749749+ w.WriteHeader(http.StatusOK)
750750+ }))
751751+752752+ // Get token first
753753+ getReq := httptest.NewRequest("GET", "/", nil)
754754+ getRec := httptest.NewRecorder()
755755+ handler.ServeHTTP(getRec, getReq)
756756+757757+ var token string
758758+ for _, c := range getRec.Result().Cookies() {
759759+ if c.Name == CSRFTokenCookieName {
760760+ token = c.Value
761761+ break
762762+ }
763763+ }
764764+765765+ // POST with form field instead of header
766766+ formData := "csrf_token=" + token + "&name=test"
767767+ postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader(formData))
768768+ postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
769769+ postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
770770+ postRec := httptest.NewRecorder()
771771+772772+ handler.ServeHTTP(postRec, postReq)
773773+774774+ if postRec.Code != http.StatusOK {
775775+ t.Errorf("POST with form field CSRF token should succeed, got %d", postRec.Code)
776776+ }
777777+}
778778+779779+func TestCSRFMiddleware_DELETE(t *testing.T) {
780780+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
781781+ w.WriteHeader(http.StatusOK)
782782+ }))
783783+784784+ // DELETE without token should fail
785785+ req := httptest.NewRequest("DELETE", "/api/beans/123", nil)
786786+ rec := httptest.NewRecorder()
787787+788788+ handler.ServeHTTP(rec, req)
789789+790790+ if rec.Code != http.StatusForbidden {
791791+ t.Errorf("DELETE without CSRF token should return 403, got %d", rec.Code)
792792+ }
793793+}
794794+795795+func TestCSRFMiddleware_PUT(t *testing.T) {
796796+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
797797+ w.WriteHeader(http.StatusOK)
798798+ }))
799799+800800+ // PUT without token should fail
801801+ req := httptest.NewRequest("PUT", "/api/beans/123", strings.NewReader("{}"))
802802+ rec := httptest.NewRecorder()
803803+804804+ handler.ServeHTTP(rec, req)
805805+806806+ if rec.Code != http.StatusForbidden {
807807+ t.Errorf("PUT without CSRF token should return 403, got %d", rec.Code)
808808+ }
809809+}
810810+```
811811+812812+### Integration Tests
813813+814814+**File:** `test/csrf/main_test.go`
815815+816816+These tests run against a real server instance:
817817+818818+```go
819819+//go:build integration
820820+821821+package csrf_test
822822+823823+import (
824824+ "encoding/json"
825825+ "io"
826826+ "net/http"
827827+ "net/http/cookiejar"
828828+ "net/url"
829829+ "os"
830830+ "strings"
831831+ "testing"
832832+ "time"
833833+)
834834+835835+var serverURL string
836836+837837+func TestMain(m *testing.M) {
838838+ // Get server URL from environment or use default test port
839839+ serverURL = os.Getenv("TEST_SERVER_URL")
840840+ if serverURL == "" {
841841+ serverURL = "http://localhost:18911"
842842+ }
843843+844844+ // Wait for server to be ready
845845+ client := &http.Client{Timeout: 1 * time.Second}
846846+ for i := 0; i < 30; i++ {
847847+ resp, err := client.Get(serverURL + "/")
848848+ if err == nil {
849849+ resp.Body.Close()
850850+ break
851851+ }
852852+ time.Sleep(100 * time.Millisecond)
853853+ }
854854+855855+ os.Exit(m.Run())
856856+}
857857+858858+// Helper to create client with cookie jar
859859+func newClient() *http.Client {
860860+ jar, _ := cookiejar.New(nil)
861861+ return &http.Client{
862862+ Jar: jar,
863863+ Timeout: 10 * time.Second,
864864+ CheckRedirect: func(req *http.Request, via []*http.Request) error {
865865+ return http.ErrUseLastResponse // Don't follow redirects
866866+ },
867867+ }
868868+}
869869+870870+// Helper to get CSRF token from cookie
871871+func getCSRFToken(client *http.Client, serverURL string) (string, error) {
872872+ u, _ := url.Parse(serverURL)
873873+ for _, cookie := range client.Jar.Cookies(u) {
874874+ if cookie.Name == "csrf_token" {
875875+ return cookie.Value, nil
876876+ }
877877+ }
878878+ return "", nil
879879+}
880880+881881+func TestCSRF_HomePageSetsToken(t *testing.T) {
882882+ client := newClient()
883883+884884+ resp, err := client.Get(serverURL + "/")
885885+ if err != nil {
886886+ t.Fatalf("Failed to get home page: %v", err)
887887+ }
888888+ defer resp.Body.Close()
889889+890890+ token, _ := getCSRFToken(client, serverURL)
891891+ if token == "" {
892892+ t.Error("CSRF token cookie not set on home page")
893893+ }
894894+}
895895+896896+func TestCSRF_LoginWithoutToken_Fails(t *testing.T) {
897897+ client := newClient()
898898+899899+ // First visit to get cookies
900900+ client.Get(serverURL + "/")
901901+902902+ // Try login without CSRF token
903903+ form := url.Values{}
904904+ form.Set("handle", "test.bsky.social")
905905+906906+ req, _ := http.NewRequest("POST", serverURL+"/auth/login", strings.NewReader(form.Encode()))
907907+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
908908+909909+ // Clear the CSRF token header (simulating attack)
910910+ resp, err := client.Do(req)
911911+ if err != nil {
912912+ t.Fatalf("Request failed: %v", err)
913913+ }
914914+ defer resp.Body.Close()
915915+916916+ // Without token, should get 403
917917+ if resp.StatusCode != http.StatusForbidden {
918918+ body, _ := io.ReadAll(resp.Body)
919919+ t.Errorf("Expected 403 Forbidden, got %d: %s", resp.StatusCode, string(body))
920920+ }
921921+}
922922+923923+func TestCSRF_LoginWithToken_Succeeds(t *testing.T) {
924924+ client := newClient()
925925+926926+ // First visit to get CSRF cookie
927927+ client.Get(serverURL + "/")
928928+929929+ token, _ := getCSRFToken(client, serverURL)
930930+ if token == "" {
931931+ t.Fatal("No CSRF token cookie")
932932+ }
933933+934934+ // Submit login with CSRF token
935935+ form := url.Values{}
936936+ form.Set("handle", "test.bsky.social")
937937+ form.Set("csrf_token", token)
938938+939939+ req, _ := http.NewRequest("POST", serverURL+"/auth/login", strings.NewReader(form.Encode()))
940940+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
941941+942942+ resp, err := client.Do(req)
943943+ if err != nil {
944944+ t.Fatalf("Request failed: %v", err)
945945+ }
946946+ defer resp.Body.Close()
947947+948948+ // Should redirect (302) to OAuth, not 403
949949+ if resp.StatusCode == http.StatusForbidden {
950950+ body, _ := io.ReadAll(resp.Body)
951951+ t.Errorf("CSRF validation failed with valid token: %s", string(body))
952952+ }
953953+}
954954+955955+func TestCSRF_APIDeleteWithoutToken_Fails(t *testing.T) {
956956+ client := newClient()
957957+958958+ // First visit to get cookies
959959+ client.Get(serverURL + "/")
960960+961961+ // Try DELETE without CSRF token
962962+ req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test123", nil)
963963+964964+ resp, err := client.Do(req)
965965+ if err != nil {
966966+ t.Fatalf("Request failed: %v", err)
967967+ }
968968+ defer resp.Body.Close()
969969+970970+ if resp.StatusCode != http.StatusForbidden {
971971+ t.Errorf("Expected 403, got %d", resp.StatusCode)
972972+ }
973973+}
974974+975975+func TestCSRF_APIDeleteWithToken_PassesValidation(t *testing.T) {
976976+ client := newClient()
977977+978978+ // First visit to get CSRF cookie
979979+ client.Get(serverURL + "/")
980980+981981+ token, _ := getCSRFToken(client, serverURL)
982982+983983+ // Try DELETE with CSRF token
984984+ req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test123", nil)
985985+ req.Header.Set("X-CSRF-Token", token)
986986+987987+ resp, err := client.Do(req)
988988+ if err != nil {
989989+ t.Fatalf("Request failed: %v", err)
990990+ }
991991+ defer resp.Body.Close()
992992+993993+ // Should NOT be 403 (might be 401 for auth, or 404 for not found, but not 403 CSRF)
994994+ if resp.StatusCode == http.StatusForbidden {
995995+ body, _ := io.ReadAll(resp.Body)
996996+ if strings.Contains(string(body), "CSRF") {
997997+ t.Errorf("CSRF validation failed with valid token")
998998+ }
999999+ }
10001000+}
10011001+10021002+func TestCSRF_APIPOSTWithToken_PassesValidation(t *testing.T) {
10031003+ client := newClient()
10041004+10051005+ // First visit to get CSRF cookie
10061006+ client.Get(serverURL + "/")
10071007+10081008+ token, _ := getCSRFToken(client, serverURL)
10091009+10101010+ // Try POST with CSRF token
10111011+ body, _ := json.Marshal(map[string]string{"name": "Test Bean"})
10121012+ req, _ := http.NewRequest("POST", serverURL+"/api/beans", strings.NewReader(string(body)))
10131013+ req.Header.Set("Content-Type", "application/json")
10141014+ req.Header.Set("X-CSRF-Token", token)
10151015+10161016+ resp, err := client.Do(req)
10171017+ if err != nil {
10181018+ t.Fatalf("Request failed: %v", err)
10191019+ }
10201020+ defer resp.Body.Close()
10211021+10221022+ // Should NOT be 403 CSRF error (might be 401 for auth)
10231023+ if resp.StatusCode == http.StatusForbidden {
10241024+ respBody, _ := io.ReadAll(resp.Body)
10251025+ if strings.Contains(string(respBody), "CSRF") {
10261026+ t.Errorf("CSRF validation failed with valid token")
10271027+ }
10281028+ }
10291029+}
10301030+10311031+func TestCSRF_CrossOriginAttack_Fails(t *testing.T) {
10321032+ // Simulate cross-origin attack: attacker has no way to get CSRF token
10331033+ client := &http.Client{Timeout: 10 * time.Second}
10341034+10351035+ // Attack request without any cookies or tokens
10361036+ form := url.Values{}
10371037+ form.Set("handle", "victim.bsky.social")
10381038+10391039+ req, _ := http.NewRequest("POST", serverURL+"/auth/login", strings.NewReader(form.Encode()))
10401040+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
10411041+ // No CSRF token - simulating cross-origin request
10421042+10431043+ resp, err := client.Do(req)
10441044+ if err != nil {
10451045+ t.Fatalf("Request failed: %v", err)
10461046+ }
10471047+ defer resp.Body.Close()
10481048+10491049+ if resp.StatusCode != http.StatusForbidden {
10501050+ t.Errorf("Cross-origin attack should be blocked, got status %d", resp.StatusCode)
10511051+ }
10521052+}
10531053+10541054+func TestCSRF_TokenReuse_Works(t *testing.T) {
10551055+ client := newClient()
10561056+10571057+ // Get token
10581058+ client.Get(serverURL + "/")
10591059+ token, _ := getCSRFToken(client, serverURL)
10601060+10611061+ // Use token multiple times (should work until it expires)
10621062+ for i := 0; i < 3; i++ {
10631063+ req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test"+string(rune(i)), nil)
10641064+ req.Header.Set("X-CSRF-Token", token)
10651065+10661066+ resp, err := client.Do(req)
10671067+ if err != nil {
10681068+ t.Fatalf("Request %d failed: %v", i, err)
10691069+ }
10701070+ resp.Body.Close()
10711071+10721072+ if resp.StatusCode == http.StatusForbidden {
10731073+ body, _ := io.ReadAll(resp.Body)
10741074+ if strings.Contains(string(body), "CSRF") {
10751075+ t.Errorf("Token should be reusable, failed on request %d", i)
10761076+ }
10771077+ }
10781078+ }
10791079+}
10801080+10811081+func TestCSRF_MismatchedTokens_Fails(t *testing.T) {
10821082+ client := newClient()
10831083+10841084+ // Get legitimate token
10851085+ client.Get(serverURL + "/")
10861086+ token, _ := getCSRFToken(client, serverURL)
10871087+10881088+ // Send request with different token in header vs cookie
10891089+ req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test123", nil)
10901090+ req.Header.Set("X-CSRF-Token", "completely-different-token")
10911091+ // Cookie still has real token (from jar)
10921092+10931093+ resp, err := client.Do(req)
10941094+ if err != nil {
10951095+ t.Fatalf("Request failed: %v", err)
10961096+ }
10971097+ defer resp.Body.Close()
10981098+10991099+ if resp.StatusCode != http.StatusForbidden {
11001100+ t.Errorf("Mismatched tokens should return 403, got %d (cookie: %s)", resp.StatusCode, token)
11011101+ }
11021102+}
11031103+```
11041104+11051105+### Running Tests
11061106+11071107+**File:** `test/csrf/README.md`
11081108+11091109+```markdown
11101110+# CSRF Integration Tests
11111111+11121112+These tests verify CSRF protection works correctly against a running server.
11131113+11141114+## Prerequisites
11151115+11161116+1. Build the server
11171117+2. Have the CSRF middleware implemented
11181118+11191119+## Running Tests
11201120+11211121+### Option 1: Start server manually
11221122+11231123+```bash
11241124+# Terminal 1: Start test server on different port
11251125+PORT=18911 go run cmd/server/main.go
11261126+11271127+# Terminal 2: Run tests
11281128+TEST_SERVER_URL=http://localhost:18911 go test -tags=integration ./test/csrf/...
11291129+```
11301130+11311131+### Option 2: Use test script
11321132+11331133+```bash
11341134+./test/csrf/run_tests.sh
11351135+```
11361136+11371137+## Test Coverage
11381138+11391139+| Test | Validates |
11401140+|------|-----------|
11411141+| `TestCSRF_HomePageSetsToken` | Token cookie is set on first visit |
11421142+| `TestCSRF_LoginWithoutToken_Fails` | Form POST without token blocked |
11431143+| `TestCSRF_LoginWithToken_Succeeds` | Form POST with token allowed |
11441144+| `TestCSRF_APIDeleteWithoutToken_Fails` | API DELETE without token blocked |
11451145+| `TestCSRF_APIDeleteWithToken_PassesValidation` | API DELETE with token allowed |
11461146+| `TestCSRF_APIPOSTWithToken_PassesValidation` | API POST with token allowed |
11471147+| `TestCSRF_CrossOriginAttack_Fails` | Attack without cookies blocked |
11481148+| `TestCSRF_TokenReuse_Works` | Token can be used multiple times |
11491149+| `TestCSRF_MismatchedTokens_Fails` | Wrong token in header blocked |
11501150+```
11511151+11521152+**File:** `test/csrf/run_tests.sh`
11531153+11541154+```bash
11551155+#!/bin/bash
11561156+11571157+# CSRF Integration Test Runner
11581158+# Starts a test server, runs tests, then cleans up
11591159+11601160+set -e
11611161+11621162+PORT=18911
11631163+SERVER_PID=""
11641164+TEST_DB="/tmp/arabica-csrf-test.db"
11651165+11661166+cleanup() {
11671167+ if [ -n "$SERVER_PID" ]; then
11681168+ echo "Stopping test server (PID: $SERVER_PID)..."
11691169+ kill $SERVER_PID 2>/dev/null || true
11701170+ wait $SERVER_PID 2>/dev/null || true
11711171+ fi
11721172+ rm -f "$TEST_DB"
11731173+}
11741174+11751175+trap cleanup EXIT
11761176+11771177+echo "=== CSRF Integration Tests ==="
11781178+echo ""
11791179+11801180+# Build server
11811181+echo "Building server..."
11821182+go build -o /tmp/arabica-csrf-test ./cmd/server/main.go
11831183+11841184+# Start server
11851185+echo "Starting test server on port $PORT..."
11861186+ARABICA_DB_PATH="$TEST_DB" PORT=$PORT /tmp/arabica-csrf-test &
11871187+SERVER_PID=$!
11881188+11891189+# Wait for server to be ready
11901190+echo "Waiting for server to start..."
11911191+for i in $(seq 1 30); do
11921192+ if curl -s "http://localhost:$PORT/" > /dev/null 2>&1; then
11931193+ echo "Server ready!"
11941194+ break
11951195+ fi
11961196+ sleep 0.1
11971197+done
11981198+11991199+# Run tests
12001200+echo ""
12011201+echo "Running tests..."
12021202+TEST_SERVER_URL="http://localhost:$PORT" go test -v -tags=integration ./test/csrf/...
12031203+12041204+echo ""
12051205+echo "=== Tests Complete ==="
12061206+```
12071207+12081208+---
12091209+12101210+## Rollback Plan
12111211+12121212+If CSRF protection causes issues:
12131213+12141214+### Quick Disable
12151215+12161216+1. Comment out CSRF middleware in `internal/routing/routing.go`
12171217+2. Rebuild and deploy
12181218+12191219+### Gradual Rollout
12201220+12211221+1. Start with `ExemptPaths: []string{"/*"}` (exempt all paths)
12221222+2. Remove exemptions one endpoint at a time
12231223+3. Monitor for 403 errors in logs
12241224+12251225+### Feature Flag
12261226+12271227+Add environment variable:
12281228+12291229+```go
12301230+// In routing.go
12311231+if os.Getenv("ENABLE_CSRF") == "true" {
12321232+ handler = middleware.CSRFMiddleware(csrfConfig)(handler)
12331233+}
12341234+```
12351235+12361236+---
12371237+12381238+## Monitoring
12391239+12401240+### Metrics to Watch
12411241+12421242+1. **403 Forbidden rate** - Sudden spike indicates broken CSRF
12431243+2. **Login success rate** - Drop indicates form not sending token
12441244+3. **API error rate** - Increase indicates JS not sending headers
12451245+12461246+### Log Messages
12471247+12481248+The middleware should log:
12491249+- Token generation (DEBUG level)
12501250+- Validation failures (WARN level) with client IP, path, method
12511251+12521252+```go
12531253+log.Warn().
12541254+ Str("client_ip", getClientIP(r)).
12551255+ Str("path", r.URL.Path).
12561256+ Str("method", r.Method).
12571257+ Msg("CSRF validation failed")
12581258+```
12591259+12601260+---
12611261+12621262+## Timeline
12631263+12641264+| Day | Task |
12651265+|-----|------|
12661266+| 1 | Implement middleware, unit tests |
12671267+| 2 | Update templates, test forms |
12681268+| 3 | Update JavaScript, test fetch calls |
12691269+| 4 | Integration tests, manual QA |
12701270+| 5 | Deploy to staging, monitor |
12711271+| 6 | Deploy to production |
12721272+12731273+---
12741274+12751275+## Checklist
12761276+12771277+### Backend
12781278+- [ ] Create `internal/middleware/csrf.go`
12791279+- [ ] Add unit tests
12801280+- [ ] Update routing to include middleware
12811281+- [ ] Update PageData with CSRFToken
12821282+- [ ] Update all render functions
12831283+- [ ] Update all handlers
12841284+12851285+### Frontend
12861286+- [ ] Create `web/static/js/csrf.js`
12871287+- [ ] Update `templates/layout.tmpl`
12881288+- [ ] Update `templates/home.tmpl` (login/logout forms)
12891289+- [ ] Update `templates/brew_form.tmpl`
12901290+- [ ] Update `templates/partials/brew_list_content.tmpl`
12911291+- [ ] Update `web/static/js/manage-page.js`
12921292+- [ ] Update `web/static/js/brew-form.js`
12931293+12941294+### Testing
12951295+- [ ] Unit tests pass
12961296+- [ ] Integration tests pass
12971297+- [ ] Manual testing: login form
12981298+- [ ] Manual testing: logout form
12991299+- [ ] Manual testing: create brew
13001300+- [ ] Manual testing: edit brew
13011301+- [ ] Manual testing: delete brew
13021302+- [ ] Manual testing: manage page CRUD
13031303+- [ ] Test cross-origin attack blocked
13041304+13051305+### Deployment
13061306+- [ ] Deploy to staging
13071307+- [ ] Verify no 403 errors
13081308+- [ ] Deploy to production
13091309+- [ ] Monitor error rates
+157
internal/middleware/csrf.go
···11+package middleware
22+33+import (
44+ "crypto/rand"
55+ "crypto/subtle"
66+ "encoding/base64"
77+ "net/http"
88+ "strings"
99+1010+ "github.com/rs/zerolog/log"
1111+)
1212+1313+const (
1414+ // CSRFTokenCookieName is the name of the cookie that stores the CSRF token
1515+ CSRFTokenCookieName = "csrf_token"
1616+ // CSRFTokenHeaderName is the HTTP header name for submitting the CSRF token
1717+ CSRFTokenHeaderName = "X-CSRF-Token"
1818+ // CSRFTokenFormField is the form field name for submitting the CSRF token
1919+ CSRFTokenFormField = "csrf_token"
2020+ // CSRFTokenLength is the number of random bytes used to generate the token
2121+ CSRFTokenLength = 32
2222+)
2323+2424+// CSRFConfig holds CSRF middleware configuration
2525+type CSRFConfig struct {
2626+ // SecureCookie sets the Secure flag on the CSRF cookie
2727+ SecureCookie bool
2828+2929+ // ExemptPaths are paths that skip CSRF validation (e.g., OAuth callback)
3030+ ExemptPaths []string
3131+3232+ // ExemptMethods are HTTP methods that skip CSRF validation
3333+ // Default: GET, HEAD, OPTIONS, TRACE
3434+ ExemptMethods []string
3535+}
3636+3737+// DefaultCSRFConfig returns default configuration
3838+func DefaultCSRFConfig() *CSRFConfig {
3939+ return &CSRFConfig{
4040+ SecureCookie: false,
4141+ ExemptPaths: []string{"/oauth/callback"},
4242+ ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"},
4343+ }
4444+}
4545+4646+// generateCSRFToken creates a cryptographically secure random token
4747+func generateCSRFToken() (string, error) {
4848+ bytes := make([]byte, CSRFTokenLength)
4949+ if _, err := rand.Read(bytes); err != nil {
5050+ return "", err
5151+ }
5252+ return base64.URLEncoding.EncodeToString(bytes), nil
5353+}
5454+5555+// CSRFMiddleware provides CSRF protection using double-submit cookie pattern
5656+func CSRFMiddleware(config *CSRFConfig) func(http.Handler) http.Handler {
5757+ if config == nil {
5858+ config = DefaultCSRFConfig()
5959+ }
6060+6161+ // Build exempt method set for fast lookup
6262+ exemptMethods := make(map[string]bool)
6363+ for _, m := range config.ExemptMethods {
6464+ exemptMethods[strings.ToUpper(m)] = true
6565+ }
6666+6767+ return func(next http.Handler) http.Handler {
6868+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6969+ // Get or generate CSRF token
7070+ var token string
7171+ cookie, err := r.Cookie(CSRFTokenCookieName)
7272+ if err == nil && cookie.Value != "" {
7373+ token = cookie.Value
7474+ } else {
7575+ // Generate new token
7676+ token, err = generateCSRFToken()
7777+ if err != nil {
7878+ log.Error().Err(err).Msg("Failed to generate CSRF token")
7979+ http.Error(w, "Internal server error", http.StatusInternalServerError)
8080+ return
8181+ }
8282+8383+ // Set cookie
8484+ http.SetCookie(w, &http.Cookie{
8585+ Name: CSRFTokenCookieName,
8686+ Value: token,
8787+ Path: "/",
8888+ HttpOnly: false, // JS needs to read this
8989+ Secure: config.SecureCookie,
9090+ SameSite: http.SameSiteStrictMode,
9191+ MaxAge: 86400, // 24 hours
9292+ })
9393+ }
9494+9595+ // Store token in response header for JS to access
9696+ // This is an alternative to reading from cookie
9797+ w.Header().Set(CSRFTokenHeaderName, token)
9898+9999+ // Check if method requires validation
100100+ if exemptMethods[r.Method] {
101101+ next.ServeHTTP(w, r)
102102+ return
103103+ }
104104+105105+ // Check if path is exempt
106106+ for _, path := range config.ExemptPaths {
107107+ if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path) {
108108+ next.ServeHTTP(w, r)
109109+ return
110110+ }
111111+ }
112112+113113+ // Validate CSRF token
114114+ // Try header first (JavaScript requests)
115115+ submittedToken := r.Header.Get(CSRFTokenHeaderName)
116116+117117+ // Fall back to form field (traditional forms)
118118+ if submittedToken == "" {
119119+ submittedToken = r.FormValue(CSRFTokenFormField)
120120+ }
121121+122122+ // Validate token
123123+ if submittedToken == "" {
124124+ log.Warn().
125125+ Str("client_ip", getClientIP(r)).
126126+ Str("path", r.URL.Path).
127127+ Str("method", r.Method).
128128+ Msg("CSRF token missing")
129129+ http.Error(w, "CSRF token missing", http.StatusForbidden)
130130+ return
131131+ }
132132+133133+ // Constant-time comparison to prevent timing attacks
134134+ if subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 {
135135+ log.Warn().
136136+ Str("client_ip", getClientIP(r)).
137137+ Str("path", r.URL.Path).
138138+ Str("method", r.Method).
139139+ Msg("CSRF token invalid")
140140+ http.Error(w, "CSRF token invalid", http.StatusForbidden)
141141+ return
142142+ }
143143+144144+ next.ServeHTTP(w, r)
145145+ })
146146+ }
147147+}
148148+149149+// GetCSRFToken extracts the CSRF token from request cookies
150150+// Used by template rendering to include token in forms
151151+func GetCSRFToken(r *http.Request) string {
152152+ cookie, err := r.Cookie(CSRFTokenCookieName)
153153+ if err != nil {
154154+ return ""
155155+ }
156156+ return cookie.Value
157157+}
+455
internal/middleware/csrf_test.go
···11+package middleware
22+33+import (
44+ "net/http"
55+ "net/http/httptest"
66+ "strings"
77+ "testing"
88+)
99+1010+func TestCSRFTokenGeneration(t *testing.T) {
1111+ // Test that tokens are generated correctly
1212+ token, err := generateCSRFToken()
1313+ if err != nil {
1414+ t.Fatalf("Failed to generate token: %v", err)
1515+ }
1616+ if len(token) == 0 {
1717+ t.Error("Generated token is empty")
1818+ }
1919+2020+ // Test uniqueness
2121+ token2, _ := generateCSRFToken()
2222+ if token == token2 {
2323+ t.Error("Tokens should be unique")
2424+ }
2525+}
2626+2727+func TestCSRFMiddleware_SetsTokenCookie(t *testing.T) {
2828+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2929+ w.WriteHeader(http.StatusOK)
3030+ }))
3131+3232+ req := httptest.NewRequest("GET", "/", nil)
3333+ rec := httptest.NewRecorder()
3434+3535+ handler.ServeHTTP(rec, req)
3636+3737+ // Check cookie is set
3838+ cookies := rec.Result().Cookies()
3939+ var csrfCookie *http.Cookie
4040+ for _, c := range cookies {
4141+ if c.Name == CSRFTokenCookieName {
4242+ csrfCookie = c
4343+ break
4444+ }
4545+ }
4646+4747+ if csrfCookie == nil {
4848+ t.Error("CSRF cookie not set")
4949+ }
5050+ if csrfCookie.Value == "" {
5151+ t.Error("CSRF cookie value is empty")
5252+ }
5353+ // Verify cookie settings
5454+ if csrfCookie.HttpOnly {
5555+ t.Error("CSRF cookie should not be HttpOnly (JS needs to read it)")
5656+ }
5757+ if csrfCookie.SameSite != http.SameSiteStrictMode {
5858+ t.Error("CSRF cookie should have SameSite=Strict")
5959+ }
6060+}
6161+6262+func TestCSRFMiddleware_SetsResponseHeader(t *testing.T) {
6363+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
6464+ w.WriteHeader(http.StatusOK)
6565+ }))
6666+6767+ req := httptest.NewRequest("GET", "/", nil)
6868+ rec := httptest.NewRecorder()
6969+7070+ handler.ServeHTTP(rec, req)
7171+7272+ // Check response header is set
7373+ headerToken := rec.Header().Get(CSRFTokenHeaderName)
7474+ if headerToken == "" {
7575+ t.Error("CSRF token not set in response header")
7676+ }
7777+}
7878+7979+func TestCSRFMiddleware_GETRequestsExempt(t *testing.T) {
8080+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8181+ w.WriteHeader(http.StatusOK)
8282+ }))
8383+8484+ // GET without token should succeed
8585+ req := httptest.NewRequest("GET", "/some-page", nil)
8686+ rec := httptest.NewRecorder()
8787+8888+ handler.ServeHTTP(rec, req)
8989+9090+ if rec.Code != http.StatusOK {
9191+ t.Errorf("GET request should succeed, got status %d", rec.Code)
9292+ }
9393+}
9494+9595+func TestCSRFMiddleware_HEADRequestsExempt(t *testing.T) {
9696+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
9797+ w.WriteHeader(http.StatusOK)
9898+ }))
9999+100100+ req := httptest.NewRequest("HEAD", "/some-page", nil)
101101+ rec := httptest.NewRecorder()
102102+103103+ handler.ServeHTTP(rec, req)
104104+105105+ if rec.Code != http.StatusOK {
106106+ t.Errorf("HEAD request should succeed, got status %d", rec.Code)
107107+ }
108108+}
109109+110110+func TestCSRFMiddleware_OPTIONSRequestsExempt(t *testing.T) {
111111+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
112112+ w.WriteHeader(http.StatusOK)
113113+ }))
114114+115115+ req := httptest.NewRequest("OPTIONS", "/some-page", nil)
116116+ rec := httptest.NewRecorder()
117117+118118+ handler.ServeHTTP(rec, req)
119119+120120+ if rec.Code != http.StatusOK {
121121+ t.Errorf("OPTIONS request should succeed, got status %d", rec.Code)
122122+ }
123123+}
124124+125125+func TestCSRFMiddleware_POSTWithoutToken_Fails(t *testing.T) {
126126+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
127127+ w.WriteHeader(http.StatusOK)
128128+ }))
129129+130130+ // POST without token should fail
131131+ req := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}"))
132132+ req.Header.Set("Content-Type", "application/json")
133133+ rec := httptest.NewRecorder()
134134+135135+ handler.ServeHTTP(rec, req)
136136+137137+ if rec.Code != http.StatusForbidden {
138138+ t.Errorf("POST without CSRF token should return 403, got %d", rec.Code)
139139+ }
140140+}
141141+142142+func TestCSRFMiddleware_POSTWithValidToken_Succeeds(t *testing.T) {
143143+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
144144+ w.WriteHeader(http.StatusOK)
145145+ }))
146146+147147+ // First, get a token via GET
148148+ getReq := httptest.NewRequest("GET", "/", nil)
149149+ getRec := httptest.NewRecorder()
150150+ handler.ServeHTTP(getRec, getReq)
151151+152152+ var token string
153153+ for _, c := range getRec.Result().Cookies() {
154154+ if c.Name == CSRFTokenCookieName {
155155+ token = c.Value
156156+ break
157157+ }
158158+ }
159159+160160+ if token == "" {
161161+ t.Fatal("No CSRF token cookie was set")
162162+ }
163163+164164+ // Now POST with valid token
165165+ postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}"))
166166+ postReq.Header.Set("Content-Type", "application/json")
167167+ postReq.Header.Set(CSRFTokenHeaderName, token)
168168+ postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
169169+ postRec := httptest.NewRecorder()
170170+171171+ handler.ServeHTTP(postRec, postReq)
172172+173173+ if postRec.Code != http.StatusOK {
174174+ t.Errorf("POST with valid CSRF token should succeed, got %d", postRec.Code)
175175+ }
176176+}
177177+178178+func TestCSRFMiddleware_POSTWithInvalidToken_Fails(t *testing.T) {
179179+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
180180+ w.WriteHeader(http.StatusOK)
181181+ }))
182182+183183+ // First, get a token
184184+ getReq := httptest.NewRequest("GET", "/", nil)
185185+ getRec := httptest.NewRecorder()
186186+ handler.ServeHTTP(getRec, getReq)
187187+188188+ var token string
189189+ for _, c := range getRec.Result().Cookies() {
190190+ if c.Name == CSRFTokenCookieName {
191191+ token = c.Value
192192+ break
193193+ }
194194+ }
195195+196196+ // POST with wrong token
197197+ postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}"))
198198+ postReq.Header.Set("Content-Type", "application/json")
199199+ postReq.Header.Set(CSRFTokenHeaderName, "wrong-token")
200200+ postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
201201+ postRec := httptest.NewRecorder()
202202+203203+ handler.ServeHTTP(postRec, postReq)
204204+205205+ if postRec.Code != http.StatusForbidden {
206206+ t.Errorf("POST with invalid CSRF token should return 403, got %d", postRec.Code)
207207+ }
208208+}
209209+210210+func TestCSRFMiddleware_ExemptPath(t *testing.T) {
211211+ config := &CSRFConfig{
212212+ ExemptPaths: []string{"/oauth/callback"},
213213+ ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"},
214214+ }
215215+ handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216216+ w.WriteHeader(http.StatusOK)
217217+ }))
218218+219219+ // POST to exempt path without token should succeed
220220+ req := httptest.NewRequest("POST", "/oauth/callback", nil)
221221+ rec := httptest.NewRecorder()
222222+223223+ handler.ServeHTTP(rec, req)
224224+225225+ if rec.Code != http.StatusOK {
226226+ t.Errorf("Exempt path should succeed without token, got %d", rec.Code)
227227+ }
228228+}
229229+230230+func TestCSRFMiddleware_ExemptPathPrefix(t *testing.T) {
231231+ config := &CSRFConfig{
232232+ ExemptPaths: []string{"/oauth/"},
233233+ ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"},
234234+ }
235235+ handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
236236+ w.WriteHeader(http.StatusOK)
237237+ }))
238238+239239+ // POST to path with exempt prefix without token should succeed
240240+ req := httptest.NewRequest("POST", "/oauth/callback?code=123", nil)
241241+ rec := httptest.NewRecorder()
242242+243243+ handler.ServeHTTP(rec, req)
244244+245245+ if rec.Code != http.StatusOK {
246246+ t.Errorf("Exempt path prefix should succeed without token, got %d", rec.Code)
247247+ }
248248+}
249249+250250+func TestCSRFMiddleware_FormField(t *testing.T) {
251251+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
252252+ w.WriteHeader(http.StatusOK)
253253+ }))
254254+255255+ // Get token first
256256+ getReq := httptest.NewRequest("GET", "/", nil)
257257+ getRec := httptest.NewRecorder()
258258+ handler.ServeHTTP(getRec, getReq)
259259+260260+ var token string
261261+ for _, c := range getRec.Result().Cookies() {
262262+ if c.Name == CSRFTokenCookieName {
263263+ token = c.Value
264264+ break
265265+ }
266266+ }
267267+268268+ // POST with form field instead of header
269269+ formData := "csrf_token=" + token + "&name=test"
270270+ postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader(formData))
271271+ postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
272272+ postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
273273+ postRec := httptest.NewRecorder()
274274+275275+ handler.ServeHTTP(postRec, postReq)
276276+277277+ if postRec.Code != http.StatusOK {
278278+ t.Errorf("POST with form field CSRF token should succeed, got %d", postRec.Code)
279279+ }
280280+}
281281+282282+func TestCSRFMiddleware_DELETE(t *testing.T) {
283283+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
284284+ w.WriteHeader(http.StatusOK)
285285+ }))
286286+287287+ // DELETE without token should fail
288288+ req := httptest.NewRequest("DELETE", "/api/beans/123", nil)
289289+ rec := httptest.NewRecorder()
290290+291291+ handler.ServeHTTP(rec, req)
292292+293293+ if rec.Code != http.StatusForbidden {
294294+ t.Errorf("DELETE without CSRF token should return 403, got %d", rec.Code)
295295+ }
296296+}
297297+298298+func TestCSRFMiddleware_PUT(t *testing.T) {
299299+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
300300+ w.WriteHeader(http.StatusOK)
301301+ }))
302302+303303+ // PUT without token should fail
304304+ req := httptest.NewRequest("PUT", "/api/beans/123", strings.NewReader("{}"))
305305+ rec := httptest.NewRecorder()
306306+307307+ handler.ServeHTTP(rec, req)
308308+309309+ if rec.Code != http.StatusForbidden {
310310+ t.Errorf("PUT without CSRF token should return 403, got %d", rec.Code)
311311+ }
312312+}
313313+314314+func TestCSRFMiddleware_DELETE_WithValidToken(t *testing.T) {
315315+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
316316+ w.WriteHeader(http.StatusOK)
317317+ }))
318318+319319+ // Get token first
320320+ getReq := httptest.NewRequest("GET", "/", nil)
321321+ getRec := httptest.NewRecorder()
322322+ handler.ServeHTTP(getRec, getReq)
323323+324324+ var token string
325325+ for _, c := range getRec.Result().Cookies() {
326326+ if c.Name == CSRFTokenCookieName {
327327+ token = c.Value
328328+ break
329329+ }
330330+ }
331331+332332+ // DELETE with valid token
333333+ req := httptest.NewRequest("DELETE", "/api/beans/123", nil)
334334+ req.Header.Set(CSRFTokenHeaderName, token)
335335+ req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
336336+ rec := httptest.NewRecorder()
337337+338338+ handler.ServeHTTP(rec, req)
339339+340340+ if rec.Code != http.StatusOK {
341341+ t.Errorf("DELETE with valid CSRF token should succeed, got %d", rec.Code)
342342+ }
343343+}
344344+345345+func TestCSRFMiddleware_PUT_WithValidToken(t *testing.T) {
346346+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
347347+ w.WriteHeader(http.StatusOK)
348348+ }))
349349+350350+ // Get token first
351351+ getReq := httptest.NewRequest("GET", "/", nil)
352352+ getRec := httptest.NewRecorder()
353353+ handler.ServeHTTP(getRec, getReq)
354354+355355+ var token string
356356+ for _, c := range getRec.Result().Cookies() {
357357+ if c.Name == CSRFTokenCookieName {
358358+ token = c.Value
359359+ break
360360+ }
361361+ }
362362+363363+ // PUT with valid token
364364+ req := httptest.NewRequest("PUT", "/api/beans/123", strings.NewReader("{}"))
365365+ req.Header.Set("Content-Type", "application/json")
366366+ req.Header.Set(CSRFTokenHeaderName, token)
367367+ req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token})
368368+ rec := httptest.NewRecorder()
369369+370370+ handler.ServeHTTP(rec, req)
371371+372372+ if rec.Code != http.StatusOK {
373373+ t.Errorf("PUT with valid CSRF token should succeed, got %d", rec.Code)
374374+ }
375375+}
376376+377377+func TestCSRFMiddleware_SecureCookie(t *testing.T) {
378378+ config := &CSRFConfig{
379379+ SecureCookie: true,
380380+ ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"},
381381+ }
382382+ handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
383383+ w.WriteHeader(http.StatusOK)
384384+ }))
385385+386386+ req := httptest.NewRequest("GET", "/", nil)
387387+ rec := httptest.NewRecorder()
388388+389389+ handler.ServeHTTP(rec, req)
390390+391391+ // Check cookie has Secure flag
392392+ cookies := rec.Result().Cookies()
393393+ var csrfCookie *http.Cookie
394394+ for _, c := range cookies {
395395+ if c.Name == CSRFTokenCookieName {
396396+ csrfCookie = c
397397+ break
398398+ }
399399+ }
400400+401401+ if csrfCookie == nil {
402402+ t.Fatal("CSRF cookie not set")
403403+ }
404404+ if !csrfCookie.Secure {
405405+ t.Error("CSRF cookie should have Secure flag when SecureCookie=true")
406406+ }
407407+}
408408+409409+func TestGetCSRFToken(t *testing.T) {
410410+ // Create request with CSRF cookie
411411+ req := httptest.NewRequest("GET", "/", nil)
412412+ req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: "test-token-123"})
413413+414414+ token := GetCSRFToken(req)
415415+ if token != "test-token-123" {
416416+ t.Errorf("GetCSRFToken returned wrong value: %s", token)
417417+ }
418418+}
419419+420420+func TestGetCSRFToken_NoCookie(t *testing.T) {
421421+ req := httptest.NewRequest("GET", "/", nil)
422422+423423+ token := GetCSRFToken(req)
424424+ if token != "" {
425425+ t.Errorf("GetCSRFToken should return empty string when no cookie: %s", token)
426426+ }
427427+}
428428+429429+func TestCSRFMiddleware_ReusesExistingToken(t *testing.T) {
430430+ handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
431431+ w.WriteHeader(http.StatusOK)
432432+ }))
433433+434434+ // Make request with existing token
435435+ existingToken := "existing-token-abc123"
436436+ req := httptest.NewRequest("GET", "/", nil)
437437+ req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: existingToken})
438438+ rec := httptest.NewRecorder()
439439+440440+ handler.ServeHTTP(rec, req)
441441+442442+ // Should not set a new cookie (only set when no token exists)
443443+ cookies := rec.Result().Cookies()
444444+ for _, c := range cookies {
445445+ if c.Name == CSRFTokenCookieName {
446446+ t.Error("Should not set new CSRF cookie when valid one already exists")
447447+ }
448448+ }
449449+450450+ // Response header should contain the existing token
451451+ headerToken := rec.Header().Get(CSRFTokenHeaderName)
452452+ if headerToken != existingToken {
453453+ t.Errorf("Response header should contain existing token, got: %s", headerToken)
454454+ }
455455+}