Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: CSRF hardening

pdewey b215fc2e a41637c9

+2072 -19
+12
CLAUDE.md
··· 260 260 3. **Public by default** - Social interactions are public records, readable by anyone 261 261 4. **Portable identity** - Users can switch PDS and keep their social graph 262 262 263 + ## Deployment Notes 264 + 265 + ### CSS Cache Busting 266 + 267 + When making CSS/style changes, bump the version query parameter in `templates/layout.tmpl`: 268 + 269 + ```html 270 + <link rel="stylesheet" href="/static/css/output.css?v=0.1.3" /> 271 + ``` 272 + 273 + Cloudflare caches static assets, so incrementing the version ensures users get the updated styles. 274 + 263 275 ## Known Issues / TODOs 264 276 265 277 See todo list in conversation for tracked issues. Key areas:
+1309
docs/csrf-implementation-plan.md
··· 1 + # CSRF Protection Implementation Plan 2 + 3 + This document provides a comprehensive plan for implementing CSRF (Cross-Site Request Forgery) protection in Arabica. 4 + 5 + ## Table of Contents 6 + 7 + 1. [Overview](#overview) 8 + 2. [Architecture](#architecture) 9 + 3. [Implementation Steps](#implementation-steps) 10 + 4. [Files to Modify](#files-to-modify) 11 + 5. [Testing Plan](#testing-plan) 12 + 6. [Rollback Plan](#rollback-plan) 13 + 14 + --- 15 + 16 + ## Overview 17 + 18 + ### What We're Protecting 19 + 20 + All state-changing endpoints (POST, PUT, DELETE) that modify user data: 21 + 22 + | Endpoint | Method | Purpose | 23 + |----------|--------|---------| 24 + | `/auth/login` | POST | User login | 25 + | `/logout` | POST | User logout | 26 + | `/brews` | POST | Create brew | 27 + | `/brews/{id}` | PUT | Update brew | 28 + | `/brews/{id}` | DELETE | Delete brew | 29 + | `/api/beans` | POST | Create bean | 30 + | `/api/beans/{id}` | PUT | Update bean | 31 + | `/api/beans/{id}` | DELETE | Delete bean | 32 + | `/api/roasters` | POST | Create roaster | 33 + | `/api/roasters/{id}` | PUT | Update roaster | 34 + | `/api/roasters/{id}` | DELETE | Delete roaster | 35 + | `/api/grinders` | POST | Create grinder | 36 + | `/api/grinders/{id}` | PUT | Update grinder | 37 + | `/api/grinders/{id}` | DELETE | Delete grinder | 38 + | `/api/brewers` | POST | Create brewer | 39 + | `/api/brewers/{id}` | PUT | Update brewer | 40 + | `/api/brewers/{id}` | DELETE | Delete brewer | 41 + 42 + ### Exempt Endpoints 43 + 44 + | Endpoint | Reason | 45 + |----------|--------| 46 + | `GET /oauth/callback` | Uses OAuth `state` parameter for CSRF protection | 47 + | `GET /*` | Read-only, no state changes | 48 + 49 + --- 50 + 51 + ## Architecture 52 + 53 + ### Token Strategy: Double Submit Cookie 54 + 55 + We'll use the **Double Submit Cookie** pattern because: 56 + 1. Stateless - No server-side token storage needed 57 + 2. Works well with HTMX and JavaScript fetch calls 58 + 3. Simple to implement 59 + 60 + **How it works:** 61 + 62 + ``` 63 + 1. Server generates random CSRF token 64 + 2. Token sent to browser in TWO ways: 65 + a) As a cookie: `csrf_token=abc123` (HttpOnly=false, so JS can read it) 66 + b) Embedded in page (meta tag or JS variable) 67 + 68 + 3. Browser must send token back in TWO ways: 69 + a) Automatically via cookie (browser does this) 70 + b) Manually via header `X-CSRF-Token: abc123` or form field 71 + 72 + 4. Server validates: cookie token == header/form token 73 + ``` 74 + 75 + **Why this works:** 76 + - Attacker on `evil.com` can trigger requests with cookies (browser sends automatically) 77 + - But attacker CANNOT read the cookie value (Same-Origin Policy) 78 + - So attacker cannot include matching header/form value 79 + - Request is rejected 80 + 81 + ### Token Lifecycle 82 + 83 + ``` 84 + ┌─────────────────────────────────────────────────────────────────┐ 85 + │ Token Generation │ 86 + ├─────────────────────────────────────────────────────────────────┤ 87 + │ When: First request from a browser (no existing CSRF cookie) │ 88 + │ How: Generate 32-byte random token using crypto/rand │ 89 + │ Set: Cookie `csrf_token` with value │ 90 + │ - Path: / │ 91 + │ - HttpOnly: false (JS needs to read it) │ 92 + │ - Secure: true (in production) │ 93 + │ - SameSite: Strict │ 94 + │ - MaxAge: 86400 (24 hours, shorter than session) │ 95 + └─────────────────────────────────────────────────────────────────┘ 96 + 97 + ┌─────────────────────────────────────────────────────────────────┐ 98 + │ Token Validation │ 99 + ├─────────────────────────────────────────────────────────────────┤ 100 + │ When: Any POST, PUT, DELETE, PATCH request │ 101 + │ Steps: │ 102 + │ 1. Read token from cookie │ 103 + │ 2. Read token from header (X-CSRF-Token) OR form (csrf_token) │ 104 + │ 3. Compare using constant-time comparison │ 105 + │ 4. If mismatch or missing: HTTP 403 Forbidden │ 106 + │ 5. If match: Continue to handler │ 107 + └─────────────────────────────────────────────────────────────────┘ 108 + ``` 109 + 110 + --- 111 + 112 + ## Implementation Steps 113 + 114 + ### Phase 1: Backend Middleware (Go) 115 + 116 + #### 1.1 Create CSRF Middleware 117 + 118 + **File:** `internal/middleware/csrf.go` 119 + 120 + ```go 121 + package middleware 122 + 123 + import ( 124 + "crypto/rand" 125 + "crypto/subtle" 126 + "encoding/base64" 127 + "net/http" 128 + "strings" 129 + ) 130 + 131 + const ( 132 + CSRFTokenCookieName = "csrf_token" 133 + CSRFTokenHeaderName = "X-CSRF-Token" 134 + CSRFTokenFormField = "csrf_token" 135 + CSRFTokenLength = 32 136 + ) 137 + 138 + // CSRFConfig holds CSRF middleware configuration 139 + type CSRFConfig struct { 140 + // SecureCookie sets the Secure flag on the CSRF cookie 141 + SecureCookie bool 142 + 143 + // ExemptPaths are paths that skip CSRF validation (e.g., OAuth callback) 144 + ExemptPaths []string 145 + 146 + // ExemptMethods are HTTP methods that skip CSRF validation 147 + // Default: GET, HEAD, OPTIONS, TRACE 148 + ExemptMethods []string 149 + } 150 + 151 + // DefaultCSRFConfig returns default configuration 152 + func DefaultCSRFConfig() *CSRFConfig { 153 + return &CSRFConfig{ 154 + SecureCookie: false, 155 + ExemptPaths: []string{"/oauth/callback"}, 156 + ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, 157 + } 158 + } 159 + 160 + // generateToken creates a cryptographically secure random token 161 + func generateToken() (string, error) { 162 + bytes := make([]byte, CSRFTokenLength) 163 + if _, err := rand.Read(bytes); err != nil { 164 + return "", err 165 + } 166 + return base64.URLEncoding.EncodeToString(bytes), nil 167 + } 168 + 169 + // CSRFMiddleware provides CSRF protection using double-submit cookie pattern 170 + func CSRFMiddleware(config *CSRFConfig) func(http.Handler) http.Handler { 171 + if config == nil { 172 + config = DefaultCSRFConfig() 173 + } 174 + 175 + // Build exempt method set for fast lookup 176 + exemptMethods := make(map[string]bool) 177 + for _, m := range config.ExemptMethods { 178 + exemptMethods[strings.ToUpper(m)] = true 179 + } 180 + 181 + return func(next http.Handler) http.Handler { 182 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 183 + // Get or generate CSRF token 184 + var token string 185 + cookie, err := r.Cookie(CSRFTokenCookieName) 186 + if err == nil && cookie.Value != "" { 187 + token = cookie.Value 188 + } else { 189 + // Generate new token 190 + token, err = generateToken() 191 + if err != nil { 192 + http.Error(w, "Internal server error", http.StatusInternalServerError) 193 + return 194 + } 195 + 196 + // Set cookie 197 + http.SetCookie(w, &http.Cookie{ 198 + Name: CSRFTokenCookieName, 199 + Value: token, 200 + Path: "/", 201 + HttpOnly: false, // JS needs to read this 202 + Secure: config.SecureCookie, 203 + SameSite: http.SameSiteStrictMode, 204 + MaxAge: 86400, // 24 hours 205 + }) 206 + } 207 + 208 + // Store token in response header for JS to access 209 + // This is an alternative to reading from cookie 210 + w.Header().Set("X-CSRF-Token", token) 211 + 212 + // Check if method requires validation 213 + if exemptMethods[r.Method] { 214 + next.ServeHTTP(w, r) 215 + return 216 + } 217 + 218 + // Check if path is exempt 219 + for _, path := range config.ExemptPaths { 220 + if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path) { 221 + next.ServeHTTP(w, r) 222 + return 223 + } 224 + } 225 + 226 + // Validate CSRF token 227 + // Try header first (JavaScript requests) 228 + submittedToken := r.Header.Get(CSRFTokenHeaderName) 229 + 230 + // Fall back to form field (traditional forms) 231 + if submittedToken == "" { 232 + submittedToken = r.FormValue(CSRFTokenFormField) 233 + } 234 + 235 + // Validate token 236 + if submittedToken == "" { 237 + http.Error(w, "CSRF token missing", http.StatusForbidden) 238 + return 239 + } 240 + 241 + // Constant-time comparison to prevent timing attacks 242 + if subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 { 243 + http.Error(w, "CSRF token invalid", http.StatusForbidden) 244 + return 245 + } 246 + 247 + next.ServeHTTP(w, r) 248 + }) 249 + } 250 + } 251 + 252 + // GetCSRFToken extracts the CSRF token from request cookies 253 + // Used by template rendering to include token in forms 254 + func GetCSRFToken(r *http.Request) string { 255 + cookie, err := r.Cookie(CSRFTokenCookieName) 256 + if err != nil { 257 + return "" 258 + } 259 + return cookie.Value 260 + } 261 + ``` 262 + 263 + #### 1.2 Update Routing 264 + 265 + **File:** `internal/routing/routing.go` 266 + 267 + Add CSRF middleware to the chain: 268 + 269 + ```go 270 + // Apply middleware in order (outermost first, innermost last) 271 + var handler http.Handler = mux 272 + 273 + // 1. Limit request body size (innermost - runs first on request) 274 + handler = middleware.LimitBodyMiddleware(handler) 275 + 276 + // 2. Apply CSRF protection (before auth, validates tokens) 277 + csrfConfig := &middleware.CSRFConfig{ 278 + SecureCookie: cfg.SecureCookies, // Pass from config 279 + ExemptPaths: []string{"/oauth/callback"}, 280 + } 281 + handler = middleware.CSRFMiddleware(csrfConfig)(handler) 282 + 283 + // 3. Apply OAuth middleware to add auth context 284 + handler = cfg.OAuthManager.AuthMiddleware(handler) 285 + 286 + // ... rest of middleware 287 + ``` 288 + 289 + #### 1.3 Update BFF/Render to Include Token 290 + 291 + **File:** `internal/bff/render.go` 292 + 293 + Add CSRF token to PageData: 294 + 295 + ```go 296 + type PageData struct { 297 + Title string 298 + IsAuthenticated bool 299 + UserDID string 300 + CSRFToken string // ADD THIS 301 + // ... other fields 302 + } 303 + 304 + // Update all Render* functions to accept and include CSRF token 305 + func RenderHome(w http.ResponseWriter, isAuthenticated bool, userDID string, feedItems []*feed.FeedItem, csrfToken string) error { 306 + data := &HomePageData{ 307 + PageData: PageData{ 308 + Title: "Arabica - Coffee Brew Tracker", 309 + IsAuthenticated: isAuthenticated, 310 + UserDID: userDID, 311 + CSRFToken: csrfToken, 312 + }, 313 + // ... 314 + } 315 + // ... 316 + } 317 + ``` 318 + 319 + #### 1.4 Update Handlers to Pass Token 320 + 321 + **File:** `internal/handlers/handlers.go` 322 + 323 + Update handlers to pass CSRF token to templates: 324 + 325 + ```go 326 + func (h *Handler) HandleHome(w http.ResponseWriter, r *http.Request) { 327 + csrfToken := middleware.GetCSRFToken(r) 328 + // ... pass csrfToken to render function 329 + } 330 + ``` 331 + 332 + ### Phase 2: Frontend Changes 333 + 334 + #### 2.1 Update Templates with Hidden Fields 335 + 336 + **File:** `templates/home.tmpl` 337 + 338 + Add CSRF token to login form: 339 + 340 + ```html 341 + <form method="POST" action="/auth/login" class="max-w-md mx-auto"> 342 + <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> 343 + <!-- rest of form --> 344 + </form> 345 + ``` 346 + 347 + Add CSRF token to logout form: 348 + 349 + ```html 350 + <form action="/logout" method="POST" class="inline-block"> 351 + <input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> 352 + <button type="submit">Logout</button> 353 + </form> 354 + ``` 355 + 356 + **File:** `templates/brew_form.tmpl` 357 + 358 + Add CSRF token and configure HTMX: 359 + 360 + ```html 361 + <form id="brew-form" 362 + {{if .Brew}} 363 + hx-put="/brews/{{.Brew.RKey}}" 364 + {{else}} 365 + hx-post="/brews" 366 + {{end}} 367 + hx-headers='{"X-CSRF-Token": "{{.CSRFToken}}"}' 368 + hx-target="body" 369 + hx-swap="none"> 370 + 371 + <!-- form fields --> 372 + </form> 373 + ``` 374 + 375 + **File:** `templates/partials/brew_list_content.tmpl` 376 + 377 + Add CSRF header to delete button: 378 + 379 + ```html 380 + <button hx-delete="/brews/{{.RKey}}" 381 + hx-headers='{"X-CSRF-Token": "{{$.CSRFToken}}"}' 382 + hx-confirm="Are you sure?" 383 + hx-target="closest .brew-card" 384 + hx-swap="outerHTML"> 385 + Delete 386 + </button> 387 + ``` 388 + 389 + #### 2.2 Update JavaScript Files 390 + 391 + **File:** `web/static/js/csrf.js` (NEW) 392 + 393 + Create helper for CSRF token: 394 + 395 + ```javascript 396 + /** 397 + * CSRF Token Helper 398 + * 399 + * Usage: 400 + * import { getCSRFToken, csrfFetch } from './csrf.js'; 401 + * 402 + * // Manual header 403 + * fetch('/api/beans', { 404 + * method: 'POST', 405 + * headers: { 'X-CSRF-Token': getCSRFToken() } 406 + * }); 407 + * 408 + * // Or use wrapper 409 + * csrfFetch('/api/beans', { method: 'POST', body: data }); 410 + */ 411 + 412 + /** 413 + * Get CSRF token from cookie 414 + */ 415 + export function getCSRFToken() { 416 + const name = 'csrf_token='; 417 + const decodedCookie = decodeURIComponent(document.cookie); 418 + const cookies = decodedCookie.split(';'); 419 + 420 + for (let cookie of cookies) { 421 + cookie = cookie.trim(); 422 + if (cookie.indexOf(name) === 0) { 423 + return cookie.substring(name.length); 424 + } 425 + } 426 + return ''; 427 + } 428 + 429 + /** 430 + * Fetch wrapper that automatically includes CSRF token 431 + */ 432 + export async function csrfFetch(url, options = {}) { 433 + const headers = options.headers || {}; 434 + 435 + // Only add CSRF token for state-changing methods 436 + const method = (options.method || 'GET').toUpperCase(); 437 + if (!['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method)) { 438 + headers['X-CSRF-Token'] = getCSRFToken(); 439 + } 440 + 441 + return fetch(url, { ...options, headers }); 442 + } 443 + 444 + // Configure HTMX to include CSRF token on all requests 445 + document.addEventListener('DOMContentLoaded', function() { 446 + document.body.addEventListener('htmx:configRequest', function(event) { 447 + // Add CSRF token header to all HTMX requests 448 + event.detail.headers['X-CSRF-Token'] = getCSRFToken(); 449 + }); 450 + }); 451 + ``` 452 + 453 + **File:** `web/static/js/manage-page.js` 454 + 455 + Update all fetch calls: 456 + 457 + ```javascript 458 + // At top of file 459 + import { getCSRFToken } from './csrf.js'; 460 + 461 + // Update all fetch calls to include header 462 + async function saveBean(data, rkey = null) { 463 + const url = rkey ? `/api/beans/${rkey}` : '/api/beans'; 464 + const method = rkey ? 'PUT' : 'POST'; 465 + 466 + const response = await fetch(url, { 467 + method: method, 468 + headers: { 469 + 'Content-Type': 'application/json', 470 + 'X-CSRF-Token': getCSRFToken() // ADD THIS 471 + }, 472 + body: JSON.stringify(data) 473 + }); 474 + // ... 475 + } 476 + 477 + async function deleteBean(rkey) { 478 + const response = await fetch(`/api/beans/${rkey}`, { 479 + method: 'DELETE', 480 + headers: { 481 + 'X-CSRF-Token': getCSRFToken() // ADD THIS 482 + } 483 + }); 484 + // ... 485 + } 486 + 487 + // Apply same pattern to all other CRUD functions: 488 + // - saveRoaster, deleteRoaster 489 + // - saveGrinder, deleteGrinder 490 + // - saveBrewer, deleteBrewer 491 + ``` 492 + 493 + **File:** `web/static/js/brew-form.js` 494 + 495 + Update inline creation fetch calls: 496 + 497 + ```javascript 498 + import { getCSRFToken } from './csrf.js'; 499 + 500 + // Update bean creation 501 + async function createInlineBean() { 502 + const response = await fetch('/api/beans', { 503 + method: 'POST', 504 + headers: { 505 + 'Content-Type': 'application/json', 506 + 'X-CSRF-Token': getCSRFToken() // ADD THIS 507 + }, 508 + body: JSON.stringify(beanData) 509 + }); 510 + // ... 511 + } 512 + 513 + // Apply to grinder and brewer creation too 514 + ``` 515 + 516 + #### 2.3 Update Layout Template 517 + 518 + **File:** `templates/layout.tmpl` 519 + 520 + Include CSRF script globally: 521 + 522 + ```html 523 + <head> 524 + <!-- ... --> 525 + <script type="module" src="/static/js/csrf.js"></script> 526 + </head> 527 + ``` 528 + 529 + Or add meta tag for non-module scripts: 530 + 531 + ```html 532 + <head> 533 + <meta name="csrf-token" content="{{.CSRFToken}}"> 534 + </head> 535 + ``` 536 + 537 + --- 538 + 539 + ## Files to Modify 540 + 541 + ### New Files 542 + 543 + | File | Purpose | 544 + |------|---------| 545 + | `internal/middleware/csrf.go` | CSRF middleware implementation | 546 + | `internal/middleware/csrf_test.go` | Unit tests for CSRF middleware | 547 + | `web/static/js/csrf.js` | JavaScript CSRF helper | 548 + | `test/csrf/csrf_test.go` | Integration tests | 549 + | `test/csrf/server_test.go` | Test server setup | 550 + 551 + ### Modified Files 552 + 553 + | File | Changes | 554 + |------|---------| 555 + | `internal/routing/routing.go` | Add CSRF middleware to chain | 556 + | `internal/bff/render.go` | Add CSRFToken to PageData and all render functions | 557 + | `internal/handlers/handlers.go` | Pass CSRF token to render functions | 558 + | `internal/handlers/auth.go` | Pass CSRF token to home render | 559 + | `templates/layout.tmpl` | Include CSRF meta tag or script | 560 + | `templates/home.tmpl` | Add hidden fields to login/logout forms | 561 + | `templates/brew_form.tmpl` | Add HTMX headers with CSRF token | 562 + | `templates/partials/brew_list_content.tmpl` | Add CSRF header to delete buttons | 563 + | `templates/partials/manage_content.tmpl` | Ensure CSRF token available | 564 + | `web/static/js/manage-page.js` | Add CSRF header to all fetch calls | 565 + | `web/static/js/brew-form.js` | Add CSRF header to inline creation | 566 + | `web/static/js/data-cache.js` | Check if any POST calls need CSRF | 567 + 568 + --- 569 + 570 + ## Testing Plan 571 + 572 + ### Unit Tests 573 + 574 + **File:** `internal/middleware/csrf_test.go` 575 + 576 + ```go 577 + package middleware 578 + 579 + import ( 580 + "net/http" 581 + "net/http/httptest" 582 + "strings" 583 + "testing" 584 + ) 585 + 586 + func TestCSRFTokenGeneration(t *testing.T) { 587 + // Test that tokens are generated correctly 588 + token, err := generateToken() 589 + if err != nil { 590 + t.Fatalf("Failed to generate token: %v", err) 591 + } 592 + if len(token) == 0 { 593 + t.Error("Generated token is empty") 594 + } 595 + 596 + // Test uniqueness 597 + token2, _ := generateToken() 598 + if token == token2 { 599 + t.Error("Tokens should be unique") 600 + } 601 + } 602 + 603 + func TestCSRFMiddleware_SetsTokenCookie(t *testing.T) { 604 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 605 + w.WriteHeader(http.StatusOK) 606 + })) 607 + 608 + req := httptest.NewRequest("GET", "/", nil) 609 + rec := httptest.NewRecorder() 610 + 611 + handler.ServeHTTP(rec, req) 612 + 613 + // Check cookie is set 614 + cookies := rec.Result().Cookies() 615 + var csrfCookie *http.Cookie 616 + for _, c := range cookies { 617 + if c.Name == CSRFTokenCookieName { 618 + csrfCookie = c 619 + break 620 + } 621 + } 622 + 623 + if csrfCookie == nil { 624 + t.Error("CSRF cookie not set") 625 + } 626 + if csrfCookie.Value == "" { 627 + t.Error("CSRF cookie value is empty") 628 + } 629 + } 630 + 631 + func TestCSRFMiddleware_GETRequestsExempt(t *testing.T) { 632 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 633 + w.WriteHeader(http.StatusOK) 634 + })) 635 + 636 + // GET without token should succeed 637 + req := httptest.NewRequest("GET", "/some-page", nil) 638 + rec := httptest.NewRecorder() 639 + 640 + handler.ServeHTTP(rec, req) 641 + 642 + if rec.Code != http.StatusOK { 643 + t.Errorf("GET request should succeed, got status %d", rec.Code) 644 + } 645 + } 646 + 647 + func TestCSRFMiddleware_POSTWithoutToken_Fails(t *testing.T) { 648 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 649 + w.WriteHeader(http.StatusOK) 650 + })) 651 + 652 + // POST without token should fail 653 + req := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}")) 654 + req.Header.Set("Content-Type", "application/json") 655 + rec := httptest.NewRecorder() 656 + 657 + handler.ServeHTTP(rec, req) 658 + 659 + if rec.Code != http.StatusForbidden { 660 + t.Errorf("POST without CSRF token should return 403, got %d", rec.Code) 661 + } 662 + } 663 + 664 + func TestCSRFMiddleware_POSTWithValidToken_Succeeds(t *testing.T) { 665 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 666 + w.WriteHeader(http.StatusOK) 667 + })) 668 + 669 + // First, get a token via GET 670 + getReq := httptest.NewRequest("GET", "/", nil) 671 + getRec := httptest.NewRecorder() 672 + handler.ServeHTTP(getRec, getReq) 673 + 674 + var token string 675 + for _, c := range getRec.Result().Cookies() { 676 + if c.Name == CSRFTokenCookieName { 677 + token = c.Value 678 + break 679 + } 680 + } 681 + 682 + // Now POST with valid token 683 + postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}")) 684 + postReq.Header.Set("Content-Type", "application/json") 685 + postReq.Header.Set(CSRFTokenHeaderName, token) 686 + postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 687 + postRec := httptest.NewRecorder() 688 + 689 + handler.ServeHTTP(postRec, postReq) 690 + 691 + if postRec.Code != http.StatusOK { 692 + t.Errorf("POST with valid CSRF token should succeed, got %d", postRec.Code) 693 + } 694 + } 695 + 696 + func TestCSRFMiddleware_POSTWithInvalidToken_Fails(t *testing.T) { 697 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 698 + w.WriteHeader(http.StatusOK) 699 + })) 700 + 701 + // First, get a token 702 + getReq := httptest.NewRequest("GET", "/", nil) 703 + getRec := httptest.NewRecorder() 704 + handler.ServeHTTP(getRec, getReq) 705 + 706 + var token string 707 + for _, c := range getRec.Result().Cookies() { 708 + if c.Name == CSRFTokenCookieName { 709 + token = c.Value 710 + break 711 + } 712 + } 713 + 714 + // POST with wrong token 715 + postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}")) 716 + postReq.Header.Set("Content-Type", "application/json") 717 + postReq.Header.Set(CSRFTokenHeaderName, "wrong-token") 718 + postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 719 + postRec := httptest.NewRecorder() 720 + 721 + handler.ServeHTTP(postRec, postReq) 722 + 723 + if postRec.Code != http.StatusForbidden { 724 + t.Errorf("POST with invalid CSRF token should return 403, got %d", postRec.Code) 725 + } 726 + } 727 + 728 + func TestCSRFMiddleware_ExemptPath(t *testing.T) { 729 + config := &CSRFConfig{ 730 + ExemptPaths: []string{"/oauth/callback"}, 731 + } 732 + handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 733 + w.WriteHeader(http.StatusOK) 734 + })) 735 + 736 + // POST to exempt path without token should succeed 737 + req := httptest.NewRequest("POST", "/oauth/callback", nil) 738 + rec := httptest.NewRecorder() 739 + 740 + handler.ServeHTTP(rec, req) 741 + 742 + if rec.Code != http.StatusOK { 743 + t.Errorf("Exempt path should succeed without token, got %d", rec.Code) 744 + } 745 + } 746 + 747 + func TestCSRFMiddleware_FormField(t *testing.T) { 748 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 749 + w.WriteHeader(http.StatusOK) 750 + })) 751 + 752 + // Get token first 753 + getReq := httptest.NewRequest("GET", "/", nil) 754 + getRec := httptest.NewRecorder() 755 + handler.ServeHTTP(getRec, getReq) 756 + 757 + var token string 758 + for _, c := range getRec.Result().Cookies() { 759 + if c.Name == CSRFTokenCookieName { 760 + token = c.Value 761 + break 762 + } 763 + } 764 + 765 + // POST with form field instead of header 766 + formData := "csrf_token=" + token + "&name=test" 767 + postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader(formData)) 768 + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") 769 + postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 770 + postRec := httptest.NewRecorder() 771 + 772 + handler.ServeHTTP(postRec, postReq) 773 + 774 + if postRec.Code != http.StatusOK { 775 + t.Errorf("POST with form field CSRF token should succeed, got %d", postRec.Code) 776 + } 777 + } 778 + 779 + func TestCSRFMiddleware_DELETE(t *testing.T) { 780 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 781 + w.WriteHeader(http.StatusOK) 782 + })) 783 + 784 + // DELETE without token should fail 785 + req := httptest.NewRequest("DELETE", "/api/beans/123", nil) 786 + rec := httptest.NewRecorder() 787 + 788 + handler.ServeHTTP(rec, req) 789 + 790 + if rec.Code != http.StatusForbidden { 791 + t.Errorf("DELETE without CSRF token should return 403, got %d", rec.Code) 792 + } 793 + } 794 + 795 + func TestCSRFMiddleware_PUT(t *testing.T) { 796 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 797 + w.WriteHeader(http.StatusOK) 798 + })) 799 + 800 + // PUT without token should fail 801 + req := httptest.NewRequest("PUT", "/api/beans/123", strings.NewReader("{}")) 802 + rec := httptest.NewRecorder() 803 + 804 + handler.ServeHTTP(rec, req) 805 + 806 + if rec.Code != http.StatusForbidden { 807 + t.Errorf("PUT without CSRF token should return 403, got %d", rec.Code) 808 + } 809 + } 810 + ``` 811 + 812 + ### Integration Tests 813 + 814 + **File:** `test/csrf/main_test.go` 815 + 816 + These tests run against a real server instance: 817 + 818 + ```go 819 + //go:build integration 820 + 821 + package csrf_test 822 + 823 + import ( 824 + "encoding/json" 825 + "io" 826 + "net/http" 827 + "net/http/cookiejar" 828 + "net/url" 829 + "os" 830 + "strings" 831 + "testing" 832 + "time" 833 + ) 834 + 835 + var serverURL string 836 + 837 + func TestMain(m *testing.M) { 838 + // Get server URL from environment or use default test port 839 + serverURL = os.Getenv("TEST_SERVER_URL") 840 + if serverURL == "" { 841 + serverURL = "http://localhost:18911" 842 + } 843 + 844 + // Wait for server to be ready 845 + client := &http.Client{Timeout: 1 * time.Second} 846 + for i := 0; i < 30; i++ { 847 + resp, err := client.Get(serverURL + "/") 848 + if err == nil { 849 + resp.Body.Close() 850 + break 851 + } 852 + time.Sleep(100 * time.Millisecond) 853 + } 854 + 855 + os.Exit(m.Run()) 856 + } 857 + 858 + // Helper to create client with cookie jar 859 + func newClient() *http.Client { 860 + jar, _ := cookiejar.New(nil) 861 + return &http.Client{ 862 + Jar: jar, 863 + Timeout: 10 * time.Second, 864 + CheckRedirect: func(req *http.Request, via []*http.Request) error { 865 + return http.ErrUseLastResponse // Don't follow redirects 866 + }, 867 + } 868 + } 869 + 870 + // Helper to get CSRF token from cookie 871 + func getCSRFToken(client *http.Client, serverURL string) (string, error) { 872 + u, _ := url.Parse(serverURL) 873 + for _, cookie := range client.Jar.Cookies(u) { 874 + if cookie.Name == "csrf_token" { 875 + return cookie.Value, nil 876 + } 877 + } 878 + return "", nil 879 + } 880 + 881 + func TestCSRF_HomePageSetsToken(t *testing.T) { 882 + client := newClient() 883 + 884 + resp, err := client.Get(serverURL + "/") 885 + if err != nil { 886 + t.Fatalf("Failed to get home page: %v", err) 887 + } 888 + defer resp.Body.Close() 889 + 890 + token, _ := getCSRFToken(client, serverURL) 891 + if token == "" { 892 + t.Error("CSRF token cookie not set on home page") 893 + } 894 + } 895 + 896 + func TestCSRF_LoginWithoutToken_Fails(t *testing.T) { 897 + client := newClient() 898 + 899 + // First visit to get cookies 900 + client.Get(serverURL + "/") 901 + 902 + // Try login without CSRF token 903 + form := url.Values{} 904 + form.Set("handle", "test.bsky.social") 905 + 906 + req, _ := http.NewRequest("POST", serverURL+"/auth/login", strings.NewReader(form.Encode())) 907 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 908 + 909 + // Clear the CSRF token header (simulating attack) 910 + resp, err := client.Do(req) 911 + if err != nil { 912 + t.Fatalf("Request failed: %v", err) 913 + } 914 + defer resp.Body.Close() 915 + 916 + // Without token, should get 403 917 + if resp.StatusCode != http.StatusForbidden { 918 + body, _ := io.ReadAll(resp.Body) 919 + t.Errorf("Expected 403 Forbidden, got %d: %s", resp.StatusCode, string(body)) 920 + } 921 + } 922 + 923 + func TestCSRF_LoginWithToken_Succeeds(t *testing.T) { 924 + client := newClient() 925 + 926 + // First visit to get CSRF cookie 927 + client.Get(serverURL + "/") 928 + 929 + token, _ := getCSRFToken(client, serverURL) 930 + if token == "" { 931 + t.Fatal("No CSRF token cookie") 932 + } 933 + 934 + // Submit login with CSRF token 935 + form := url.Values{} 936 + form.Set("handle", "test.bsky.social") 937 + form.Set("csrf_token", token) 938 + 939 + req, _ := http.NewRequest("POST", serverURL+"/auth/login", strings.NewReader(form.Encode())) 940 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 941 + 942 + resp, err := client.Do(req) 943 + if err != nil { 944 + t.Fatalf("Request failed: %v", err) 945 + } 946 + defer resp.Body.Close() 947 + 948 + // Should redirect (302) to OAuth, not 403 949 + if resp.StatusCode == http.StatusForbidden { 950 + body, _ := io.ReadAll(resp.Body) 951 + t.Errorf("CSRF validation failed with valid token: %s", string(body)) 952 + } 953 + } 954 + 955 + func TestCSRF_APIDeleteWithoutToken_Fails(t *testing.T) { 956 + client := newClient() 957 + 958 + // First visit to get cookies 959 + client.Get(serverURL + "/") 960 + 961 + // Try DELETE without CSRF token 962 + req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test123", nil) 963 + 964 + resp, err := client.Do(req) 965 + if err != nil { 966 + t.Fatalf("Request failed: %v", err) 967 + } 968 + defer resp.Body.Close() 969 + 970 + if resp.StatusCode != http.StatusForbidden { 971 + t.Errorf("Expected 403, got %d", resp.StatusCode) 972 + } 973 + } 974 + 975 + func TestCSRF_APIDeleteWithToken_PassesValidation(t *testing.T) { 976 + client := newClient() 977 + 978 + // First visit to get CSRF cookie 979 + client.Get(serverURL + "/") 980 + 981 + token, _ := getCSRFToken(client, serverURL) 982 + 983 + // Try DELETE with CSRF token 984 + req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test123", nil) 985 + req.Header.Set("X-CSRF-Token", token) 986 + 987 + resp, err := client.Do(req) 988 + if err != nil { 989 + t.Fatalf("Request failed: %v", err) 990 + } 991 + defer resp.Body.Close() 992 + 993 + // Should NOT be 403 (might be 401 for auth, or 404 for not found, but not 403 CSRF) 994 + if resp.StatusCode == http.StatusForbidden { 995 + body, _ := io.ReadAll(resp.Body) 996 + if strings.Contains(string(body), "CSRF") { 997 + t.Errorf("CSRF validation failed with valid token") 998 + } 999 + } 1000 + } 1001 + 1002 + func TestCSRF_APIPOSTWithToken_PassesValidation(t *testing.T) { 1003 + client := newClient() 1004 + 1005 + // First visit to get CSRF cookie 1006 + client.Get(serverURL + "/") 1007 + 1008 + token, _ := getCSRFToken(client, serverURL) 1009 + 1010 + // Try POST with CSRF token 1011 + body, _ := json.Marshal(map[string]string{"name": "Test Bean"}) 1012 + req, _ := http.NewRequest("POST", serverURL+"/api/beans", strings.NewReader(string(body))) 1013 + req.Header.Set("Content-Type", "application/json") 1014 + req.Header.Set("X-CSRF-Token", token) 1015 + 1016 + resp, err := client.Do(req) 1017 + if err != nil { 1018 + t.Fatalf("Request failed: %v", err) 1019 + } 1020 + defer resp.Body.Close() 1021 + 1022 + // Should NOT be 403 CSRF error (might be 401 for auth) 1023 + if resp.StatusCode == http.StatusForbidden { 1024 + respBody, _ := io.ReadAll(resp.Body) 1025 + if strings.Contains(string(respBody), "CSRF") { 1026 + t.Errorf("CSRF validation failed with valid token") 1027 + } 1028 + } 1029 + } 1030 + 1031 + func TestCSRF_CrossOriginAttack_Fails(t *testing.T) { 1032 + // Simulate cross-origin attack: attacker has no way to get CSRF token 1033 + client := &http.Client{Timeout: 10 * time.Second} 1034 + 1035 + // Attack request without any cookies or tokens 1036 + form := url.Values{} 1037 + form.Set("handle", "victim.bsky.social") 1038 + 1039 + req, _ := http.NewRequest("POST", serverURL+"/auth/login", strings.NewReader(form.Encode())) 1040 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 1041 + // No CSRF token - simulating cross-origin request 1042 + 1043 + resp, err := client.Do(req) 1044 + if err != nil { 1045 + t.Fatalf("Request failed: %v", err) 1046 + } 1047 + defer resp.Body.Close() 1048 + 1049 + if resp.StatusCode != http.StatusForbidden { 1050 + t.Errorf("Cross-origin attack should be blocked, got status %d", resp.StatusCode) 1051 + } 1052 + } 1053 + 1054 + func TestCSRF_TokenReuse_Works(t *testing.T) { 1055 + client := newClient() 1056 + 1057 + // Get token 1058 + client.Get(serverURL + "/") 1059 + token, _ := getCSRFToken(client, serverURL) 1060 + 1061 + // Use token multiple times (should work until it expires) 1062 + for i := 0; i < 3; i++ { 1063 + req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test"+string(rune(i)), nil) 1064 + req.Header.Set("X-CSRF-Token", token) 1065 + 1066 + resp, err := client.Do(req) 1067 + if err != nil { 1068 + t.Fatalf("Request %d failed: %v", i, err) 1069 + } 1070 + resp.Body.Close() 1071 + 1072 + if resp.StatusCode == http.StatusForbidden { 1073 + body, _ := io.ReadAll(resp.Body) 1074 + if strings.Contains(string(body), "CSRF") { 1075 + t.Errorf("Token should be reusable, failed on request %d", i) 1076 + } 1077 + } 1078 + } 1079 + } 1080 + 1081 + func TestCSRF_MismatchedTokens_Fails(t *testing.T) { 1082 + client := newClient() 1083 + 1084 + // Get legitimate token 1085 + client.Get(serverURL + "/") 1086 + token, _ := getCSRFToken(client, serverURL) 1087 + 1088 + // Send request with different token in header vs cookie 1089 + req, _ := http.NewRequest("DELETE", serverURL+"/api/beans/test123", nil) 1090 + req.Header.Set("X-CSRF-Token", "completely-different-token") 1091 + // Cookie still has real token (from jar) 1092 + 1093 + resp, err := client.Do(req) 1094 + if err != nil { 1095 + t.Fatalf("Request failed: %v", err) 1096 + } 1097 + defer resp.Body.Close() 1098 + 1099 + if resp.StatusCode != http.StatusForbidden { 1100 + t.Errorf("Mismatched tokens should return 403, got %d (cookie: %s)", resp.StatusCode, token) 1101 + } 1102 + } 1103 + ``` 1104 + 1105 + ### Running Tests 1106 + 1107 + **File:** `test/csrf/README.md` 1108 + 1109 + ```markdown 1110 + # CSRF Integration Tests 1111 + 1112 + These tests verify CSRF protection works correctly against a running server. 1113 + 1114 + ## Prerequisites 1115 + 1116 + 1. Build the server 1117 + 2. Have the CSRF middleware implemented 1118 + 1119 + ## Running Tests 1120 + 1121 + ### Option 1: Start server manually 1122 + 1123 + ```bash 1124 + # Terminal 1: Start test server on different port 1125 + PORT=18911 go run cmd/server/main.go 1126 + 1127 + # Terminal 2: Run tests 1128 + TEST_SERVER_URL=http://localhost:18911 go test -tags=integration ./test/csrf/... 1129 + ``` 1130 + 1131 + ### Option 2: Use test script 1132 + 1133 + ```bash 1134 + ./test/csrf/run_tests.sh 1135 + ``` 1136 + 1137 + ## Test Coverage 1138 + 1139 + | Test | Validates | 1140 + |------|-----------| 1141 + | `TestCSRF_HomePageSetsToken` | Token cookie is set on first visit | 1142 + | `TestCSRF_LoginWithoutToken_Fails` | Form POST without token blocked | 1143 + | `TestCSRF_LoginWithToken_Succeeds` | Form POST with token allowed | 1144 + | `TestCSRF_APIDeleteWithoutToken_Fails` | API DELETE without token blocked | 1145 + | `TestCSRF_APIDeleteWithToken_PassesValidation` | API DELETE with token allowed | 1146 + | `TestCSRF_APIPOSTWithToken_PassesValidation` | API POST with token allowed | 1147 + | `TestCSRF_CrossOriginAttack_Fails` | Attack without cookies blocked | 1148 + | `TestCSRF_TokenReuse_Works` | Token can be used multiple times | 1149 + | `TestCSRF_MismatchedTokens_Fails` | Wrong token in header blocked | 1150 + ``` 1151 + 1152 + **File:** `test/csrf/run_tests.sh` 1153 + 1154 + ```bash 1155 + #!/bin/bash 1156 + 1157 + # CSRF Integration Test Runner 1158 + # Starts a test server, runs tests, then cleans up 1159 + 1160 + set -e 1161 + 1162 + PORT=18911 1163 + SERVER_PID="" 1164 + TEST_DB="/tmp/arabica-csrf-test.db" 1165 + 1166 + cleanup() { 1167 + if [ -n "$SERVER_PID" ]; then 1168 + echo "Stopping test server (PID: $SERVER_PID)..." 1169 + kill $SERVER_PID 2>/dev/null || true 1170 + wait $SERVER_PID 2>/dev/null || true 1171 + fi 1172 + rm -f "$TEST_DB" 1173 + } 1174 + 1175 + trap cleanup EXIT 1176 + 1177 + echo "=== CSRF Integration Tests ===" 1178 + echo "" 1179 + 1180 + # Build server 1181 + echo "Building server..." 1182 + go build -o /tmp/arabica-csrf-test ./cmd/server/main.go 1183 + 1184 + # Start server 1185 + echo "Starting test server on port $PORT..." 1186 + ARABICA_DB_PATH="$TEST_DB" PORT=$PORT /tmp/arabica-csrf-test & 1187 + SERVER_PID=$! 1188 + 1189 + # Wait for server to be ready 1190 + echo "Waiting for server to start..." 1191 + for i in $(seq 1 30); do 1192 + if curl -s "http://localhost:$PORT/" > /dev/null 2>&1; then 1193 + echo "Server ready!" 1194 + break 1195 + fi 1196 + sleep 0.1 1197 + done 1198 + 1199 + # Run tests 1200 + echo "" 1201 + echo "Running tests..." 1202 + TEST_SERVER_URL="http://localhost:$PORT" go test -v -tags=integration ./test/csrf/... 1203 + 1204 + echo "" 1205 + echo "=== Tests Complete ===" 1206 + ``` 1207 + 1208 + --- 1209 + 1210 + ## Rollback Plan 1211 + 1212 + If CSRF protection causes issues: 1213 + 1214 + ### Quick Disable 1215 + 1216 + 1. Comment out CSRF middleware in `internal/routing/routing.go` 1217 + 2. Rebuild and deploy 1218 + 1219 + ### Gradual Rollout 1220 + 1221 + 1. Start with `ExemptPaths: []string{"/*"}` (exempt all paths) 1222 + 2. Remove exemptions one endpoint at a time 1223 + 3. Monitor for 403 errors in logs 1224 + 1225 + ### Feature Flag 1226 + 1227 + Add environment variable: 1228 + 1229 + ```go 1230 + // In routing.go 1231 + if os.Getenv("ENABLE_CSRF") == "true" { 1232 + handler = middleware.CSRFMiddleware(csrfConfig)(handler) 1233 + } 1234 + ``` 1235 + 1236 + --- 1237 + 1238 + ## Monitoring 1239 + 1240 + ### Metrics to Watch 1241 + 1242 + 1. **403 Forbidden rate** - Sudden spike indicates broken CSRF 1243 + 2. **Login success rate** - Drop indicates form not sending token 1244 + 3. **API error rate** - Increase indicates JS not sending headers 1245 + 1246 + ### Log Messages 1247 + 1248 + The middleware should log: 1249 + - Token generation (DEBUG level) 1250 + - Validation failures (WARN level) with client IP, path, method 1251 + 1252 + ```go 1253 + log.Warn(). 1254 + Str("client_ip", getClientIP(r)). 1255 + Str("path", r.URL.Path). 1256 + Str("method", r.Method). 1257 + Msg("CSRF validation failed") 1258 + ``` 1259 + 1260 + --- 1261 + 1262 + ## Timeline 1263 + 1264 + | Day | Task | 1265 + |-----|------| 1266 + | 1 | Implement middleware, unit tests | 1267 + | 2 | Update templates, test forms | 1268 + | 3 | Update JavaScript, test fetch calls | 1269 + | 4 | Integration tests, manual QA | 1270 + | 5 | Deploy to staging, monitor | 1271 + | 6 | Deploy to production | 1272 + 1273 + --- 1274 + 1275 + ## Checklist 1276 + 1277 + ### Backend 1278 + - [ ] Create `internal/middleware/csrf.go` 1279 + - [ ] Add unit tests 1280 + - [ ] Update routing to include middleware 1281 + - [ ] Update PageData with CSRFToken 1282 + - [ ] Update all render functions 1283 + - [ ] Update all handlers 1284 + 1285 + ### Frontend 1286 + - [ ] Create `web/static/js/csrf.js` 1287 + - [ ] Update `templates/layout.tmpl` 1288 + - [ ] Update `templates/home.tmpl` (login/logout forms) 1289 + - [ ] Update `templates/brew_form.tmpl` 1290 + - [ ] Update `templates/partials/brew_list_content.tmpl` 1291 + - [ ] Update `web/static/js/manage-page.js` 1292 + - [ ] Update `web/static/js/brew-form.js` 1293 + 1294 + ### Testing 1295 + - [ ] Unit tests pass 1296 + - [ ] Integration tests pass 1297 + - [ ] Manual testing: login form 1298 + - [ ] Manual testing: logout form 1299 + - [ ] Manual testing: create brew 1300 + - [ ] Manual testing: edit brew 1301 + - [ ] Manual testing: delete brew 1302 + - [ ] Manual testing: manage page CRUD 1303 + - [ ] Test cross-origin attack blocked 1304 + 1305 + ### Deployment 1306 + - [ ] Deploy to staging 1307 + - [ ] Verify no 403 errors 1308 + - [ ] Deploy to production 1309 + - [ ] Monitor error rates
+157
internal/middleware/csrf.go
··· 1 + package middleware 2 + 3 + import ( 4 + "crypto/rand" 5 + "crypto/subtle" 6 + "encoding/base64" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/rs/zerolog/log" 11 + ) 12 + 13 + const ( 14 + // CSRFTokenCookieName is the name of the cookie that stores the CSRF token 15 + CSRFTokenCookieName = "csrf_token" 16 + // CSRFTokenHeaderName is the HTTP header name for submitting the CSRF token 17 + CSRFTokenHeaderName = "X-CSRF-Token" 18 + // CSRFTokenFormField is the form field name for submitting the CSRF token 19 + CSRFTokenFormField = "csrf_token" 20 + // CSRFTokenLength is the number of random bytes used to generate the token 21 + CSRFTokenLength = 32 22 + ) 23 + 24 + // CSRFConfig holds CSRF middleware configuration 25 + type CSRFConfig struct { 26 + // SecureCookie sets the Secure flag on the CSRF cookie 27 + SecureCookie bool 28 + 29 + // ExemptPaths are paths that skip CSRF validation (e.g., OAuth callback) 30 + ExemptPaths []string 31 + 32 + // ExemptMethods are HTTP methods that skip CSRF validation 33 + // Default: GET, HEAD, OPTIONS, TRACE 34 + ExemptMethods []string 35 + } 36 + 37 + // DefaultCSRFConfig returns default configuration 38 + func DefaultCSRFConfig() *CSRFConfig { 39 + return &CSRFConfig{ 40 + SecureCookie: false, 41 + ExemptPaths: []string{"/oauth/callback"}, 42 + ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, 43 + } 44 + } 45 + 46 + // generateCSRFToken creates a cryptographically secure random token 47 + func generateCSRFToken() (string, error) { 48 + bytes := make([]byte, CSRFTokenLength) 49 + if _, err := rand.Read(bytes); err != nil { 50 + return "", err 51 + } 52 + return base64.URLEncoding.EncodeToString(bytes), nil 53 + } 54 + 55 + // CSRFMiddleware provides CSRF protection using double-submit cookie pattern 56 + func CSRFMiddleware(config *CSRFConfig) func(http.Handler) http.Handler { 57 + if config == nil { 58 + config = DefaultCSRFConfig() 59 + } 60 + 61 + // Build exempt method set for fast lookup 62 + exemptMethods := make(map[string]bool) 63 + for _, m := range config.ExemptMethods { 64 + exemptMethods[strings.ToUpper(m)] = true 65 + } 66 + 67 + return func(next http.Handler) http.Handler { 68 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 69 + // Get or generate CSRF token 70 + var token string 71 + cookie, err := r.Cookie(CSRFTokenCookieName) 72 + if err == nil && cookie.Value != "" { 73 + token = cookie.Value 74 + } else { 75 + // Generate new token 76 + token, err = generateCSRFToken() 77 + if err != nil { 78 + log.Error().Err(err).Msg("Failed to generate CSRF token") 79 + http.Error(w, "Internal server error", http.StatusInternalServerError) 80 + return 81 + } 82 + 83 + // Set cookie 84 + http.SetCookie(w, &http.Cookie{ 85 + Name: CSRFTokenCookieName, 86 + Value: token, 87 + Path: "/", 88 + HttpOnly: false, // JS needs to read this 89 + Secure: config.SecureCookie, 90 + SameSite: http.SameSiteStrictMode, 91 + MaxAge: 86400, // 24 hours 92 + }) 93 + } 94 + 95 + // Store token in response header for JS to access 96 + // This is an alternative to reading from cookie 97 + w.Header().Set(CSRFTokenHeaderName, token) 98 + 99 + // Check if method requires validation 100 + if exemptMethods[r.Method] { 101 + next.ServeHTTP(w, r) 102 + return 103 + } 104 + 105 + // Check if path is exempt 106 + for _, path := range config.ExemptPaths { 107 + if r.URL.Path == path || strings.HasPrefix(r.URL.Path, path) { 108 + next.ServeHTTP(w, r) 109 + return 110 + } 111 + } 112 + 113 + // Validate CSRF token 114 + // Try header first (JavaScript requests) 115 + submittedToken := r.Header.Get(CSRFTokenHeaderName) 116 + 117 + // Fall back to form field (traditional forms) 118 + if submittedToken == "" { 119 + submittedToken = r.FormValue(CSRFTokenFormField) 120 + } 121 + 122 + // Validate token 123 + if submittedToken == "" { 124 + log.Warn(). 125 + Str("client_ip", getClientIP(r)). 126 + Str("path", r.URL.Path). 127 + Str("method", r.Method). 128 + Msg("CSRF token missing") 129 + http.Error(w, "CSRF token missing", http.StatusForbidden) 130 + return 131 + } 132 + 133 + // Constant-time comparison to prevent timing attacks 134 + if subtle.ConstantTimeCompare([]byte(token), []byte(submittedToken)) != 1 { 135 + log.Warn(). 136 + Str("client_ip", getClientIP(r)). 137 + Str("path", r.URL.Path). 138 + Str("method", r.Method). 139 + Msg("CSRF token invalid") 140 + http.Error(w, "CSRF token invalid", http.StatusForbidden) 141 + return 142 + } 143 + 144 + next.ServeHTTP(w, r) 145 + }) 146 + } 147 + } 148 + 149 + // GetCSRFToken extracts the CSRF token from request cookies 150 + // Used by template rendering to include token in forms 151 + func GetCSRFToken(r *http.Request) string { 152 + cookie, err := r.Cookie(CSRFTokenCookieName) 153 + if err != nil { 154 + return "" 155 + } 156 + return cookie.Value 157 + }
+455
internal/middleware/csrf_test.go
··· 1 + package middleware 2 + 3 + import ( 4 + "net/http" 5 + "net/http/httptest" 6 + "strings" 7 + "testing" 8 + ) 9 + 10 + func TestCSRFTokenGeneration(t *testing.T) { 11 + // Test that tokens are generated correctly 12 + token, err := generateCSRFToken() 13 + if err != nil { 14 + t.Fatalf("Failed to generate token: %v", err) 15 + } 16 + if len(token) == 0 { 17 + t.Error("Generated token is empty") 18 + } 19 + 20 + // Test uniqueness 21 + token2, _ := generateCSRFToken() 22 + if token == token2 { 23 + t.Error("Tokens should be unique") 24 + } 25 + } 26 + 27 + func TestCSRFMiddleware_SetsTokenCookie(t *testing.T) { 28 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 29 + w.WriteHeader(http.StatusOK) 30 + })) 31 + 32 + req := httptest.NewRequest("GET", "/", nil) 33 + rec := httptest.NewRecorder() 34 + 35 + handler.ServeHTTP(rec, req) 36 + 37 + // Check cookie is set 38 + cookies := rec.Result().Cookies() 39 + var csrfCookie *http.Cookie 40 + for _, c := range cookies { 41 + if c.Name == CSRFTokenCookieName { 42 + csrfCookie = c 43 + break 44 + } 45 + } 46 + 47 + if csrfCookie == nil { 48 + t.Error("CSRF cookie not set") 49 + } 50 + if csrfCookie.Value == "" { 51 + t.Error("CSRF cookie value is empty") 52 + } 53 + // Verify cookie settings 54 + if csrfCookie.HttpOnly { 55 + t.Error("CSRF cookie should not be HttpOnly (JS needs to read it)") 56 + } 57 + if csrfCookie.SameSite != http.SameSiteStrictMode { 58 + t.Error("CSRF cookie should have SameSite=Strict") 59 + } 60 + } 61 + 62 + func TestCSRFMiddleware_SetsResponseHeader(t *testing.T) { 63 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 64 + w.WriteHeader(http.StatusOK) 65 + })) 66 + 67 + req := httptest.NewRequest("GET", "/", nil) 68 + rec := httptest.NewRecorder() 69 + 70 + handler.ServeHTTP(rec, req) 71 + 72 + // Check response header is set 73 + headerToken := rec.Header().Get(CSRFTokenHeaderName) 74 + if headerToken == "" { 75 + t.Error("CSRF token not set in response header") 76 + } 77 + } 78 + 79 + func TestCSRFMiddleware_GETRequestsExempt(t *testing.T) { 80 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 81 + w.WriteHeader(http.StatusOK) 82 + })) 83 + 84 + // GET without token should succeed 85 + req := httptest.NewRequest("GET", "/some-page", nil) 86 + rec := httptest.NewRecorder() 87 + 88 + handler.ServeHTTP(rec, req) 89 + 90 + if rec.Code != http.StatusOK { 91 + t.Errorf("GET request should succeed, got status %d", rec.Code) 92 + } 93 + } 94 + 95 + func TestCSRFMiddleware_HEADRequestsExempt(t *testing.T) { 96 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 97 + w.WriteHeader(http.StatusOK) 98 + })) 99 + 100 + req := httptest.NewRequest("HEAD", "/some-page", nil) 101 + rec := httptest.NewRecorder() 102 + 103 + handler.ServeHTTP(rec, req) 104 + 105 + if rec.Code != http.StatusOK { 106 + t.Errorf("HEAD request should succeed, got status %d", rec.Code) 107 + } 108 + } 109 + 110 + func TestCSRFMiddleware_OPTIONSRequestsExempt(t *testing.T) { 111 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 + w.WriteHeader(http.StatusOK) 113 + })) 114 + 115 + req := httptest.NewRequest("OPTIONS", "/some-page", nil) 116 + rec := httptest.NewRecorder() 117 + 118 + handler.ServeHTTP(rec, req) 119 + 120 + if rec.Code != http.StatusOK { 121 + t.Errorf("OPTIONS request should succeed, got status %d", rec.Code) 122 + } 123 + } 124 + 125 + func TestCSRFMiddleware_POSTWithoutToken_Fails(t *testing.T) { 126 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 127 + w.WriteHeader(http.StatusOK) 128 + })) 129 + 130 + // POST without token should fail 131 + req := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}")) 132 + req.Header.Set("Content-Type", "application/json") 133 + rec := httptest.NewRecorder() 134 + 135 + handler.ServeHTTP(rec, req) 136 + 137 + if rec.Code != http.StatusForbidden { 138 + t.Errorf("POST without CSRF token should return 403, got %d", rec.Code) 139 + } 140 + } 141 + 142 + func TestCSRFMiddleware_POSTWithValidToken_Succeeds(t *testing.T) { 143 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 144 + w.WriteHeader(http.StatusOK) 145 + })) 146 + 147 + // First, get a token via GET 148 + getReq := httptest.NewRequest("GET", "/", nil) 149 + getRec := httptest.NewRecorder() 150 + handler.ServeHTTP(getRec, getReq) 151 + 152 + var token string 153 + for _, c := range getRec.Result().Cookies() { 154 + if c.Name == CSRFTokenCookieName { 155 + token = c.Value 156 + break 157 + } 158 + } 159 + 160 + if token == "" { 161 + t.Fatal("No CSRF token cookie was set") 162 + } 163 + 164 + // Now POST with valid token 165 + postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}")) 166 + postReq.Header.Set("Content-Type", "application/json") 167 + postReq.Header.Set(CSRFTokenHeaderName, token) 168 + postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 169 + postRec := httptest.NewRecorder() 170 + 171 + handler.ServeHTTP(postRec, postReq) 172 + 173 + if postRec.Code != http.StatusOK { 174 + t.Errorf("POST with valid CSRF token should succeed, got %d", postRec.Code) 175 + } 176 + } 177 + 178 + func TestCSRFMiddleware_POSTWithInvalidToken_Fails(t *testing.T) { 179 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 180 + w.WriteHeader(http.StatusOK) 181 + })) 182 + 183 + // First, get a token 184 + getReq := httptest.NewRequest("GET", "/", nil) 185 + getRec := httptest.NewRecorder() 186 + handler.ServeHTTP(getRec, getReq) 187 + 188 + var token string 189 + for _, c := range getRec.Result().Cookies() { 190 + if c.Name == CSRFTokenCookieName { 191 + token = c.Value 192 + break 193 + } 194 + } 195 + 196 + // POST with wrong token 197 + postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader("{}")) 198 + postReq.Header.Set("Content-Type", "application/json") 199 + postReq.Header.Set(CSRFTokenHeaderName, "wrong-token") 200 + postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 201 + postRec := httptest.NewRecorder() 202 + 203 + handler.ServeHTTP(postRec, postReq) 204 + 205 + if postRec.Code != http.StatusForbidden { 206 + t.Errorf("POST with invalid CSRF token should return 403, got %d", postRec.Code) 207 + } 208 + } 209 + 210 + func TestCSRFMiddleware_ExemptPath(t *testing.T) { 211 + config := &CSRFConfig{ 212 + ExemptPaths: []string{"/oauth/callback"}, 213 + ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, 214 + } 215 + handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 216 + w.WriteHeader(http.StatusOK) 217 + })) 218 + 219 + // POST to exempt path without token should succeed 220 + req := httptest.NewRequest("POST", "/oauth/callback", nil) 221 + rec := httptest.NewRecorder() 222 + 223 + handler.ServeHTTP(rec, req) 224 + 225 + if rec.Code != http.StatusOK { 226 + t.Errorf("Exempt path should succeed without token, got %d", rec.Code) 227 + } 228 + } 229 + 230 + func TestCSRFMiddleware_ExemptPathPrefix(t *testing.T) { 231 + config := &CSRFConfig{ 232 + ExemptPaths: []string{"/oauth/"}, 233 + ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, 234 + } 235 + handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 236 + w.WriteHeader(http.StatusOK) 237 + })) 238 + 239 + // POST to path with exempt prefix without token should succeed 240 + req := httptest.NewRequest("POST", "/oauth/callback?code=123", nil) 241 + rec := httptest.NewRecorder() 242 + 243 + handler.ServeHTTP(rec, req) 244 + 245 + if rec.Code != http.StatusOK { 246 + t.Errorf("Exempt path prefix should succeed without token, got %d", rec.Code) 247 + } 248 + } 249 + 250 + func TestCSRFMiddleware_FormField(t *testing.T) { 251 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 252 + w.WriteHeader(http.StatusOK) 253 + })) 254 + 255 + // Get token first 256 + getReq := httptest.NewRequest("GET", "/", nil) 257 + getRec := httptest.NewRecorder() 258 + handler.ServeHTTP(getRec, getReq) 259 + 260 + var token string 261 + for _, c := range getRec.Result().Cookies() { 262 + if c.Name == CSRFTokenCookieName { 263 + token = c.Value 264 + break 265 + } 266 + } 267 + 268 + // POST with form field instead of header 269 + formData := "csrf_token=" + token + "&name=test" 270 + postReq := httptest.NewRequest("POST", "/api/beans", strings.NewReader(formData)) 271 + postReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") 272 + postReq.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 273 + postRec := httptest.NewRecorder() 274 + 275 + handler.ServeHTTP(postRec, postReq) 276 + 277 + if postRec.Code != http.StatusOK { 278 + t.Errorf("POST with form field CSRF token should succeed, got %d", postRec.Code) 279 + } 280 + } 281 + 282 + func TestCSRFMiddleware_DELETE(t *testing.T) { 283 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 284 + w.WriteHeader(http.StatusOK) 285 + })) 286 + 287 + // DELETE without token should fail 288 + req := httptest.NewRequest("DELETE", "/api/beans/123", nil) 289 + rec := httptest.NewRecorder() 290 + 291 + handler.ServeHTTP(rec, req) 292 + 293 + if rec.Code != http.StatusForbidden { 294 + t.Errorf("DELETE without CSRF token should return 403, got %d", rec.Code) 295 + } 296 + } 297 + 298 + func TestCSRFMiddleware_PUT(t *testing.T) { 299 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 300 + w.WriteHeader(http.StatusOK) 301 + })) 302 + 303 + // PUT without token should fail 304 + req := httptest.NewRequest("PUT", "/api/beans/123", strings.NewReader("{}")) 305 + rec := httptest.NewRecorder() 306 + 307 + handler.ServeHTTP(rec, req) 308 + 309 + if rec.Code != http.StatusForbidden { 310 + t.Errorf("PUT without CSRF token should return 403, got %d", rec.Code) 311 + } 312 + } 313 + 314 + func TestCSRFMiddleware_DELETE_WithValidToken(t *testing.T) { 315 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 316 + w.WriteHeader(http.StatusOK) 317 + })) 318 + 319 + // Get token first 320 + getReq := httptest.NewRequest("GET", "/", nil) 321 + getRec := httptest.NewRecorder() 322 + handler.ServeHTTP(getRec, getReq) 323 + 324 + var token string 325 + for _, c := range getRec.Result().Cookies() { 326 + if c.Name == CSRFTokenCookieName { 327 + token = c.Value 328 + break 329 + } 330 + } 331 + 332 + // DELETE with valid token 333 + req := httptest.NewRequest("DELETE", "/api/beans/123", nil) 334 + req.Header.Set(CSRFTokenHeaderName, token) 335 + req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 336 + rec := httptest.NewRecorder() 337 + 338 + handler.ServeHTTP(rec, req) 339 + 340 + if rec.Code != http.StatusOK { 341 + t.Errorf("DELETE with valid CSRF token should succeed, got %d", rec.Code) 342 + } 343 + } 344 + 345 + func TestCSRFMiddleware_PUT_WithValidToken(t *testing.T) { 346 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 347 + w.WriteHeader(http.StatusOK) 348 + })) 349 + 350 + // Get token first 351 + getReq := httptest.NewRequest("GET", "/", nil) 352 + getRec := httptest.NewRecorder() 353 + handler.ServeHTTP(getRec, getReq) 354 + 355 + var token string 356 + for _, c := range getRec.Result().Cookies() { 357 + if c.Name == CSRFTokenCookieName { 358 + token = c.Value 359 + break 360 + } 361 + } 362 + 363 + // PUT with valid token 364 + req := httptest.NewRequest("PUT", "/api/beans/123", strings.NewReader("{}")) 365 + req.Header.Set("Content-Type", "application/json") 366 + req.Header.Set(CSRFTokenHeaderName, token) 367 + req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: token}) 368 + rec := httptest.NewRecorder() 369 + 370 + handler.ServeHTTP(rec, req) 371 + 372 + if rec.Code != http.StatusOK { 373 + t.Errorf("PUT with valid CSRF token should succeed, got %d", rec.Code) 374 + } 375 + } 376 + 377 + func TestCSRFMiddleware_SecureCookie(t *testing.T) { 378 + config := &CSRFConfig{ 379 + SecureCookie: true, 380 + ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, 381 + } 382 + handler := CSRFMiddleware(config)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 383 + w.WriteHeader(http.StatusOK) 384 + })) 385 + 386 + req := httptest.NewRequest("GET", "/", nil) 387 + rec := httptest.NewRecorder() 388 + 389 + handler.ServeHTTP(rec, req) 390 + 391 + // Check cookie has Secure flag 392 + cookies := rec.Result().Cookies() 393 + var csrfCookie *http.Cookie 394 + for _, c := range cookies { 395 + if c.Name == CSRFTokenCookieName { 396 + csrfCookie = c 397 + break 398 + } 399 + } 400 + 401 + if csrfCookie == nil { 402 + t.Fatal("CSRF cookie not set") 403 + } 404 + if !csrfCookie.Secure { 405 + t.Error("CSRF cookie should have Secure flag when SecureCookie=true") 406 + } 407 + } 408 + 409 + func TestGetCSRFToken(t *testing.T) { 410 + // Create request with CSRF cookie 411 + req := httptest.NewRequest("GET", "/", nil) 412 + req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: "test-token-123"}) 413 + 414 + token := GetCSRFToken(req) 415 + if token != "test-token-123" { 416 + t.Errorf("GetCSRFToken returned wrong value: %s", token) 417 + } 418 + } 419 + 420 + func TestGetCSRFToken_NoCookie(t *testing.T) { 421 + req := httptest.NewRequest("GET", "/", nil) 422 + 423 + token := GetCSRFToken(req) 424 + if token != "" { 425 + t.Errorf("GetCSRFToken should return empty string when no cookie: %s", token) 426 + } 427 + } 428 + 429 + func TestCSRFMiddleware_ReusesExistingToken(t *testing.T) { 430 + handler := CSRFMiddleware(nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 431 + w.WriteHeader(http.StatusOK) 432 + })) 433 + 434 + // Make request with existing token 435 + existingToken := "existing-token-abc123" 436 + req := httptest.NewRequest("GET", "/", nil) 437 + req.AddCookie(&http.Cookie{Name: CSRFTokenCookieName, Value: existingToken}) 438 + rec := httptest.NewRecorder() 439 + 440 + handler.ServeHTTP(rec, req) 441 + 442 + // Should not set a new cookie (only set when no token exists) 443 + cookies := rec.Result().Cookies() 444 + for _, c := range cookies { 445 + if c.Name == CSRFTokenCookieName { 446 + t.Error("Should not set new CSRF cookie when valid one already exists") 447 + } 448 + } 449 + 450 + // Response header should contain the existing token 451 + headerToken := rec.Header().Get(CSRFTokenHeaderName) 452 + if headerToken != existingToken { 453 + t.Errorf("Response header should contain existing token, got: %s", headerToken) 454 + } 455 + }
+2 -1
internal/middleware/security.go
··· 27 27 28 28 // Content Security Policy 29 29 // Allows: self for scripts/styles, inline styles (for Tailwind), unpkg for HTMX/Alpine 30 + // Note: unsafe-eval required for Alpine.js expression evaluation (x-data, x-show, etc.) 30 31 csp := strings.Join([]string{ 31 32 "default-src 'self'", 32 - "script-src 'self' https://unpkg.com", 33 + "script-src 'self' 'unsafe-eval' https://unpkg.com", 33 34 "style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Tailwind 34 35 "img-src 'self' https: data:", // Allow external images (avatars) and data URIs 35 36 "font-src 'self'",
+10 -2
internal/routing/routing.go
··· 92 92 // 1. Limit request body size (innermost - runs first on request) 93 93 handler = middleware.LimitBodyMiddleware(handler) 94 94 95 - // 2. Apply OAuth middleware to add auth context 95 + // 2. Apply CSRF protection (validates tokens on state-changing requests) 96 + csrfConfig := &middleware.CSRFConfig{ 97 + SecureCookie: false, // Set true when using HTTPS 98 + ExemptPaths: []string{"/oauth/callback"}, 99 + ExemptMethods: []string{"GET", "HEAD", "OPTIONS", "TRACE"}, 100 + } 101 + handler = middleware.CSRFMiddleware(csrfConfig)(handler) 102 + 103 + // 3. Apply OAuth middleware to add auth context 96 104 handler = cfg.OAuthManager.AuthMiddleware(handler) 97 105 98 - // 3. Apply rate limiting 106 + // 4. Apply rate limiting 99 107 rateLimitConfig := middleware.NewDefaultRateLimitConfig() 100 108 handler = middleware.RateLimitMiddleware(rateLimitConfig)(handler) 101 109
+2
templates/home.tmpl
··· 21 21 </div> 22 22 <div class="text-center mt-6"> 23 23 <form action="/logout" method="POST" class="inline-block"> 24 + <input type="hidden" name="csrf_token" class="csrf-token-field"> 24 25 <button type="submit" 25 26 class="bg-red-600 text-white py-3 px-8 rounded-lg hover:bg-red-700 transition text-lg"> 26 27 Logout ··· 32 33 <div> 33 34 <p class="text-gray-700 mb-6 text-center">Please log in with your AT Protocol handle to start tracking your brews.</p> 34 35 <form method="POST" action="/auth/login" class="max-w-md mx-auto"> 36 + <input type="hidden" name="csrf_token" class="csrf-token-field"> 35 37 <div class="relative"> 36 38 <label for="handle" class="block text-sm font-medium text-gray-700 mb-2">Your Handle</label> 37 39 <input
+3 -1
templates/layout.tmpl
··· 7 7 <meta name="description" content="Arabica - Coffee brew tracker" /> 8 8 <meta name="theme-color" content="#4a2c2a" /> 9 9 <title>{{.Title}} - Arabica</title> 10 - <link rel="stylesheet" href="/static/css/output.css?v=0.1.2" /> 10 + <link rel="stylesheet" href="/static/css/output.css?v=0.1.3" /> 11 + <style>[x-cloak] { display: none !important; }</style> 11 12 <link rel="manifest" href="/static/manifest.json" /> 12 13 <script src="https://unpkg.com/htmx.org@2.0.8"></script> 13 14 <script src="https://unpkg.com/alpinejs@3.15.3/dist/cdn.min.js" defer></script> 15 + <script src="/static/js/csrf.js"></script> 14 16 {{if .IsAuthenticated}} 15 17 <script src="/static/js/data-cache.js"></script> 16 18 {{end}}
+4 -4
templates/partials/manage_content.tmpl
··· 200 200 </div> 201 201 202 202 <!-- Bean Form Modal --> 203 - <div x-show="showBeanForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 203 + <div x-cloak x-show="showBeanForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 204 204 <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 205 205 <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingBean ? 'Edit Bean' : 'Add Bean'"></h3> 206 206 <div class="space-y-4"> ··· 238 238 </div> 239 239 240 240 <!-- Roaster Form Modal --> 241 - <div x-show="showRoasterForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 241 + <div x-cloak x-show="showRoasterForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 242 242 <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 243 243 <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingRoaster ? 'Edit Roaster' : 'Add Roaster'"></h3> 244 244 <div class="space-y-4"> ··· 259 259 </div> 260 260 261 261 <!-- Grinder Form Modal --> 262 - <div x-show="showGrinderForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 262 + <div x-cloak x-show="showGrinderForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 263 263 <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 264 264 <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingGrinder ? 'Edit Grinder' : 'Add Grinder'"></h3> 265 265 <div class="space-y-4"> ··· 289 289 </div> 290 290 291 291 <!-- Brewer Form Modal --> 292 - <div x-show="showBrewerForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 292 + <div x-cloak x-show="showBrewerForm" class="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center z-50"> 293 293 <div class="bg-brown-100 rounded-lg border border-brown-300 p-8 max-w-md w-full mx-4"> 294 294 <h3 class="text-xl font-semibold mb-4 text-gray-800" x-text="editingBrewer ? 'Edit Brewer' : 'Add Brewer'"></h3> 295 295 <div class="space-y-4">
+12 -3
web/static/js/brew-form.js
··· 169 169 }; 170 170 const response = await fetch('/api/beans', { 171 171 method: 'POST', 172 - headers: { 'Content-Type': 'application/json' }, 172 + headers: { 173 + 'Content-Type': 'application/json', 174 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 175 + }, 173 176 body: JSON.stringify(payload) 174 177 }); 175 178 if (response.ok) { ··· 200 203 } 201 204 const response = await fetch('/api/grinders', { 202 205 method: 'POST', 203 - headers: { 'Content-Type': 'application/json' }, 206 + headers: { 207 + 'Content-Type': 'application/json', 208 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 209 + }, 204 210 body: JSON.stringify(this.newGrinder) 205 211 }); 206 212 if (response.ok) { ··· 231 237 } 232 238 const response = await fetch('/api/brewers', { 233 239 method: 'POST', 234 - headers: { 'Content-Type': 'application/json' }, 240 + headers: { 241 + 'Content-Type': 'application/json', 242 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 243 + }, 235 244 body: JSON.stringify(this.newBrewer) 236 245 }); 237 246 if (response.ok) {
+66
web/static/js/csrf.js
··· 1 + /** 2 + * CSRF Token Helper 3 + * 4 + * Provides functions to get the CSRF token from the cookie and 5 + * automatically configures HTMX to include the token on all requests. 6 + * 7 + * Usage: 8 + * // Get token manually for fetch calls 9 + * const token = getCSRFToken(); 10 + * 11 + * // Manual fetch with CSRF header 12 + * fetch('/api/beans', { 13 + * method: 'POST', 14 + * headers: { 15 + * 'Content-Type': 'application/json', 16 + * 'X-CSRF-Token': getCSRFToken() 17 + * }, 18 + * body: JSON.stringify(data) 19 + * }); 20 + */ 21 + 22 + /** 23 + * Get CSRF token from cookie 24 + * @returns {string} The CSRF token or empty string if not found 25 + */ 26 + function getCSRFToken() { 27 + const name = 'csrf_token='; 28 + const decodedCookie = decodeURIComponent(document.cookie); 29 + const cookies = decodedCookie.split(';'); 30 + 31 + for (let cookie of cookies) { 32 + cookie = cookie.trim(); 33 + if (cookie.indexOf(name) === 0) { 34 + return cookie.substring(name.length); 35 + } 36 + } 37 + return ''; 38 + } 39 + 40 + /** 41 + * Configure HTMX to automatically include CSRF token on all requests 42 + * This handles all HTMX requests (hx-get, hx-post, hx-put, hx-delete, etc.) 43 + */ 44 + document.addEventListener('DOMContentLoaded', function() { 45 + // Add CSRF token header to all HTMX requests 46 + document.body.addEventListener('htmx:configRequest', function(event) { 47 + const token = getCSRFToken(); 48 + if (token) { 49 + event.detail.headers['X-CSRF-Token'] = token; 50 + } 51 + }); 52 + 53 + // Populate hidden CSRF token fields in forms 54 + const token = getCSRFToken(); 55 + if (token) { 56 + document.querySelectorAll('.csrf-token-field').forEach(function(field) { 57 + field.value = token; 58 + }); 59 + } 60 + }); 61 + 62 + // Export for use in other modules (if using module system) 63 + // For non-module scripts, getCSRFToken is available as a global 64 + if (typeof window !== 'undefined') { 65 + window.getCSRFToken = getCSRFToken; 66 + }
+40 -8
web/static/js/manage-page.js
··· 46 46 47 47 const response = await fetch(url, { 48 48 method, 49 - headers: {'Content-Type': 'application/json'}, 49 + headers: { 50 + 'Content-Type': 'application/json', 51 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 52 + }, 50 53 body: JSON.stringify(this.beanForm) 51 54 }); 52 55 ··· 65 68 async deleteBean(rkey) { 66 69 if (!confirm('Are you sure you want to delete this bean?')) return; 67 70 68 - const response = await fetch(`/api/beans/${rkey}`, {method: 'DELETE'}); 71 + const response = await fetch(`/api/beans/${rkey}`, { 72 + method: 'DELETE', 73 + headers: { 74 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 75 + } 76 + }); 69 77 if (response.ok) { 70 78 // Invalidate cache and reload 71 79 if (window.ArabicaCache) { ··· 95 103 96 104 const response = await fetch(url, { 97 105 method, 98 - headers: {'Content-Type': 'application/json'}, 106 + headers: { 107 + 'Content-Type': 'application/json', 108 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 109 + }, 99 110 body: JSON.stringify(this.roasterForm) 100 111 }); 101 112 ··· 114 125 async deleteRoaster(rkey) { 115 126 if (!confirm('Are you sure you want to delete this roaster?')) return; 116 127 117 - const response = await fetch(`/api/roasters/${rkey}`, {method: 'DELETE'}); 128 + const response = await fetch(`/api/roasters/${rkey}`, { 129 + method: 'DELETE', 130 + headers: { 131 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 132 + } 133 + }); 118 134 if (response.ok) { 119 135 // Invalidate cache and reload 120 136 if (window.ArabicaCache) { ··· 144 160 145 161 const response = await fetch(url, { 146 162 method, 147 - headers: {'Content-Type': 'application/json'}, 163 + headers: { 164 + 'Content-Type': 'application/json', 165 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 166 + }, 148 167 body: JSON.stringify(this.grinderForm) 149 168 }); 150 169 ··· 163 182 async deleteGrinder(rkey) { 164 183 if (!confirm('Are you sure you want to delete this grinder?')) return; 165 184 166 - const response = await fetch(`/api/grinders/${rkey}`, {method: 'DELETE'}); 185 + const response = await fetch(`/api/grinders/${rkey}`, { 186 + method: 'DELETE', 187 + headers: { 188 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 189 + } 190 + }); 167 191 if (response.ok) { 168 192 // Invalidate cache and reload 169 193 if (window.ArabicaCache) { ··· 193 217 194 218 const response = await fetch(url, { 195 219 method, 196 - headers: {'Content-Type': 'application/json'}, 220 + headers: { 221 + 'Content-Type': 'application/json', 222 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 223 + }, 197 224 body: JSON.stringify(this.brewerForm) 198 225 }); 199 226 ··· 212 239 async deleteBrewer(rkey) { 213 240 if (!confirm('Are you sure you want to delete this brewer?')) return; 214 241 215 - const response = await fetch(`/api/brewers/${rkey}`, {method: 'DELETE'}); 242 + const response = await fetch(`/api/brewers/${rkey}`, { 243 + method: 'DELETE', 244 + headers: { 245 + 'X-CSRF-Token': window.getCSRFToken ? window.getCSRFToken() : '' 246 + } 247 + }); 216 248 if (response.ok) { 217 249 // Invalidate cache and reload 218 250 if (window.ArabicaCache) {