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

Configure Feed

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

fix: misc frontend security fixes

pdewey be9d9ae9 e9b394cb

+209 -1352
-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
+76
internal/bff/helpers.go
··· 5 5 import ( 6 6 "encoding/json" 7 7 "fmt" 8 + "net/url" 9 + "strings" 8 10 9 11 "arabica/internal/models" 10 12 ) ··· 157 159 func HasValue(val int) bool { 158 160 return val > 0 159 161 } 162 + 163 + // SafeAvatarURL validates and sanitizes avatar URLs to prevent XSS and other attacks. 164 + // Only allows HTTPS URLs from trusted domains (Bluesky CDN) or relative paths. 165 + // Returns a safe URL or empty string if invalid. 166 + func SafeAvatarURL(avatarURL string) string { 167 + if avatarURL == "" { 168 + return "" 169 + } 170 + 171 + // Allow relative paths (e.g., /static/icon-placeholder.svg) 172 + if strings.HasPrefix(avatarURL, "/") { 173 + // Basic validation - must start with /static/ 174 + if strings.HasPrefix(avatarURL, "/static/") { 175 + return avatarURL 176 + } 177 + return "" 178 + } 179 + 180 + // Parse the URL 181 + parsedURL, err := url.Parse(avatarURL) 182 + if err != nil { 183 + return "" 184 + } 185 + 186 + // Only allow HTTPS scheme 187 + if parsedURL.Scheme != "https" { 188 + return "" 189 + } 190 + 191 + // Whitelist trusted domains for avatar images 192 + // Bluesky uses cdn.bsky.app for avatars 193 + trustedDomains := []string{ 194 + "cdn.bsky.app", 195 + "av-cdn.bsky.app", 196 + } 197 + 198 + hostLower := strings.ToLower(parsedURL.Host) 199 + for _, domain := range trustedDomains { 200 + if hostLower == domain || strings.HasSuffix(hostLower, "."+domain) { 201 + return avatarURL 202 + } 203 + } 204 + 205 + // URL is not from a trusted domain 206 + return "" 207 + } 208 + 209 + // SafeWebsiteURL validates and sanitizes website URLs for display. 210 + // Only allows HTTP/HTTPS URLs and performs basic validation. 211 + // Returns a safe URL or empty string if invalid. 212 + func SafeWebsiteURL(websiteURL string) string { 213 + if websiteURL == "" { 214 + return "" 215 + } 216 + 217 + // Parse the URL 218 + parsedURL, err := url.Parse(websiteURL) 219 + if err != nil { 220 + return "" 221 + } 222 + 223 + // Only allow HTTP and HTTPS schemes 224 + scheme := strings.ToLower(parsedURL.Scheme) 225 + if scheme != "http" && scheme != "https" { 226 + return "" 227 + } 228 + 229 + // Basic hostname validation - must have at least one dot 230 + if !strings.Contains(parsedURL.Host, ".") { 231 + return "" 232 + } 233 + 234 + return websiteURL 235 + }
+2
internal/bff/render.go
··· 35 35 "iterateRemaining": IterateRemaining, 36 36 "hasTemp": HasTemp, 37 37 "hasValue": HasValue, 38 + "safeAvatarURL": SafeAvatarURL, 39 + "safeWebsiteURL": SafeWebsiteURL, 38 40 } 39 41 }) 40 42 return templateFuncs
+3 -3
internal/middleware/security.go
··· 26 26 w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()") 27 27 28 28 // Content Security Policy 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.) 29 + // Allows: self for scripts/styles, inline styles (for Tailwind), jsdelivr for HTMX/Alpine 30 + // Note: unsafe-eval required for Alpine.js standard build (CSP build has CDN MIME type issues) 31 31 csp := strings.Join([]string{ 32 32 "default-src 'self'", 33 - "script-src 'self' 'unsafe-eval' https://unpkg.com", 33 + "script-src 'self' 'unsafe-eval' https://cdn.jsdelivr.net", 34 34 "style-src 'self' 'unsafe-inline'", // unsafe-inline needed for Tailwind 35 35 "img-src 'self' https: data:", // Allow external images (avatars) and data URIs 36 36 "font-src 'self'",
+5 -2
templates/layout.tmpl
··· 10 10 <link rel="stylesheet" href="/static/css/output.css?v=0.1.4" /> 11 11 <style>[x-cloak] { display: none !important; }</style> 12 12 <link rel="manifest" href="/static/manifest.json" /> 13 - <script src="https://unpkg.com/htmx.org@2.0.8"></script> 14 - <script src="https://unpkg.com/alpinejs@3.15.3/dist/cdn.min.js" defer></script> 13 + <!-- Alpine.js - Using standard build due to CDN MIME type issues with CSP build --> 14 + <!-- Standard build requires unsafe-eval in CSP, but our XSS protections remain strong --> 15 + <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.15.3/dist/cdn.min.js" defer crossorigin="anonymous"></script> 16 + <!-- HTMX for dynamic content loading --> 17 + <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js" crossorigin="anonymous"></script> 15 18 <script src="/static/js/csrf.js"></script> 16 19 {{if .IsAuthenticated}} 17 20 <script src="/static/js/data-cache.js"></script>
+12 -2
templates/partials/feed.tmpl
··· 7 7 <div class="flex items-center gap-3 mb-3"> 8 8 <a href="/profile/{{.Author.Handle}}" class="flex-shrink-0"> 9 9 {{if .Author.Avatar}} 10 - <img src="{{.Author.Avatar}}" alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-400 transition" /> 10 + {{$safeAvatar := safeAvatarURL .Author.Avatar}} 11 + {{if $safeAvatar}} 12 + <img src="{{$safeAvatar}}" alt="" class="w-10 h-10 rounded-full object-cover hover:ring-2 hover:ring-brown-400 transition" /> 13 + {{else}} 14 + <div class="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center hover:ring-2 hover:ring-brown-400 transition"> 15 + <span class="text-gray-500 text-sm">?</span> 16 + </div> 17 + {{end}} 11 18 {{else}} 12 19 <div class="w-10 h-10 rounded-full bg-gray-300 flex items-center justify-center hover:ring-2 hover:ring-brown-400 transition"> 13 20 <span class="text-gray-500 text-sm">?</span> ··· 133 140 <div><span class="text-gray-500">Location:</span> {{.Roaster.Location}}</div> 134 141 {{end}} 135 142 {{if .Roaster.Website}} 136 - <div><span class="text-gray-500">Website:</span> <a href="{{.Roaster.Website}}" target="_blank" class="text-blue-600 hover:underline">{{.Roaster.Website}}</a></div> 143 + {{$safeWebsite := safeWebsiteURL .Roaster.Website}} 144 + {{if $safeWebsite}} 145 + <div><span class="text-gray-500">Website:</span> <a href="{{$safeWebsite}}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline">{{$safeWebsite}}</a></div> 146 + {{end}} 137 147 {{end}} 138 148 </div> 139 149 </div>
+12 -2
templates/profile.tmpl
··· 4 4 <div class="bg-white rounded-lg shadow-md p-6 mb-6"> 5 5 <div class="flex items-center gap-4"> 6 6 {{if .Profile.Avatar}} 7 - <img src="{{.Profile.Avatar}}" alt="" class="w-20 h-20 rounded-full object-cover" /> 7 + {{$safeAvatar := safeAvatarURL .Profile.Avatar}} 8 + {{if $safeAvatar}} 9 + <img src="{{$safeAvatar}}" alt="" class="w-20 h-20 rounded-full object-cover" /> 10 + {{else}} 11 + <div class="w-20 h-20 rounded-full bg-gray-300 flex items-center justify-center"> 12 + <span class="text-gray-500 text-2xl">?</span> 13 + </div> 14 + {{end}} 8 15 {{else}} 9 16 <div class="w-20 h-20 rounded-full bg-gray-300 flex items-center justify-center"> 10 17 <span class="text-gray-500 text-2xl">?</span> ··· 232 239 <div class="text-sm text-gray-600 mt-1"> 233 240 {{if .Location}}<span>{{.Location}}</span>{{end}} 234 241 {{if .Website}} 235 - <a href="{{.Website}}" target="_blank" class="text-blue-600 hover:underline ml-2">{{.Website}}</a> 242 + {{$safeWebsite := safeWebsiteURL .Website}} 243 + {{if $safeWebsite}} 244 + <a href="{{$safeWebsite}}" target="_blank" rel="noopener noreferrer" class="text-blue-600 hover:underline ml-2">{{$safeWebsite}}</a> 245 + {{end}} 236 246 {{end}} 237 247 </div> 238 248 </div>
+48 -9
web/static/js/brew-form.js
··· 85 85 const selectedGrinder = grinderSelect?.value || ''; 86 86 const selectedBrewer = brewerSelect?.value || ''; 87 87 88 - // Populate beans 88 + // Populate beans - using DOM methods to prevent XSS 89 89 if (beanSelect && this.beans.length > 0) { 90 - // Keep only the first option (placeholder) 91 - beanSelect.innerHTML = '<option value="">Select a bean...</option>'; 90 + // Clear existing options 91 + beanSelect.innerHTML = ''; 92 + 93 + // Add placeholder 94 + const placeholderOption = document.createElement('option'); 95 + placeholderOption.value = ''; 96 + placeholderOption.textContent = 'Select a bean...'; 97 + beanSelect.appendChild(placeholderOption); 98 + 99 + // Add bean options 92 100 this.beans.forEach(bean => { 93 101 const option = document.createElement('option'); 94 102 option.value = bean.rkey || bean.RKey; 95 103 const roasterName = bean.Roaster?.Name || bean.roaster?.name || ''; 96 104 const roasterSuffix = roasterName ? ` - ${roasterName}` : ''; 105 + // Using textContent ensures all user input is safely escaped 97 106 option.textContent = `${bean.Name || bean.name} (${bean.Origin || bean.origin} - ${bean.RoastLevel || bean.roast_level})${roasterSuffix}`; 98 107 option.className = 'truncate'; 99 108 if ((bean.rkey || bean.RKey) === selectedBean) { ··· 103 112 }); 104 113 } 105 114 106 - // Populate grinders 115 + // Populate grinders - using DOM methods to prevent XSS 107 116 if (grinderSelect && this.grinders.length > 0) { 108 - grinderSelect.innerHTML = '<option value="">Select a grinder...</option>'; 117 + // Clear existing options 118 + grinderSelect.innerHTML = ''; 119 + 120 + // Add placeholder 121 + const placeholderOption = document.createElement('option'); 122 + placeholderOption.value = ''; 123 + placeholderOption.textContent = 'Select a grinder...'; 124 + grinderSelect.appendChild(placeholderOption); 125 + 126 + // Add grinder options 109 127 this.grinders.forEach(grinder => { 110 128 const option = document.createElement('option'); 111 129 option.value = grinder.rkey || grinder.RKey; 130 + // Using textContent ensures all user input is safely escaped 112 131 option.textContent = grinder.Name || grinder.name; 113 132 option.className = 'truncate'; 114 133 if ((grinder.rkey || grinder.RKey) === selectedGrinder) { ··· 118 137 }); 119 138 } 120 139 121 - // Populate brewers 140 + // Populate brewers - using DOM methods to prevent XSS 122 141 if (brewerSelect && this.brewers.length > 0) { 123 - brewerSelect.innerHTML = '<option value="">Select brew method...</option>'; 142 + // Clear existing options 143 + brewerSelect.innerHTML = ''; 144 + 145 + // Add placeholder 146 + const placeholderOption = document.createElement('option'); 147 + placeholderOption.value = ''; 148 + placeholderOption.textContent = 'Select brew method...'; 149 + brewerSelect.appendChild(placeholderOption); 150 + 151 + // Add brewer options 124 152 this.brewers.forEach(brewer => { 125 153 const option = document.createElement('option'); 126 154 option.value = brewer.rkey || brewer.RKey; 155 + // Using textContent ensures all user input is safely escaped 127 156 option.textContent = brewer.Name || brewer.name; 128 157 option.className = 'truncate'; 129 158 if ((brewer.rkey || brewer.RKey) === selectedBrewer) { ··· 133 162 }); 134 163 } 135 164 136 - // Populate roasters in new bean modal 165 + // Populate roasters in new bean modal - using DOM methods to prevent XSS 137 166 const roasterSelect = this.$el.querySelector('select[name="roaster_rkey_modal"]'); 138 167 if (roasterSelect && this.roasters.length > 0) { 139 - roasterSelect.innerHTML = '<option value="">No roaster</option>'; 168 + // Clear existing options 169 + roasterSelect.innerHTML = ''; 170 + 171 + // Add placeholder 172 + const placeholderOption = document.createElement('option'); 173 + placeholderOption.value = ''; 174 + placeholderOption.textContent = 'No roaster'; 175 + roasterSelect.appendChild(placeholderOption); 176 + 177 + // Add roaster options 140 178 this.roasters.forEach(roaster => { 141 179 const option = document.createElement('option'); 142 180 option.value = roaster.rkey || roaster.RKey; 181 + // Using textContent ensures all user input is safely escaped 143 182 option.textContent = roaster.Name || roaster.name; 144 183 roasterSelect.appendChild(option); 145 184 });
+51 -25
web/static/js/handle-autocomplete.js
··· 56 56 return; 57 57 } 58 58 59 - // Display the actors 60 - results.innerHTML = data.actors.map(actor => { 59 + // Clear previous results 60 + results.innerHTML = ''; 61 + 62 + // Create actor elements using DOM methods to prevent XSS 63 + data.actors.forEach(actor => { 61 64 const avatarUrl = actor.avatar || '/static/icon-placeholder.svg'; 62 65 const displayName = actor.displayName || actor.handle; 63 66 64 - return ` 65 - <div class="handle-result px-3 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2" 66 - data-handle="${actor.handle}"> 67 - <img src="${avatarUrl}" 68 - alt="${displayName}" 69 - width="32" 70 - height="32" 71 - class="w-6 h-6 rounded-full object-cover flex-shrink-0" 72 - onerror="this.src='/static/icon-placeholder.svg'" /> 73 - <div class="flex-1 min-w-0"> 74 - <div class="font-medium text-sm text-gray-900 truncate">${displayName}</div> 75 - <div class="text-xs text-gray-500 truncate">@${actor.handle}</div> 76 - </div> 77 - </div> 78 - `; 79 - }).join(''); 80 - 81 - results.classList.remove('hidden'); 82 - 83 - // Add click handlers 84 - results.querySelectorAll('.handle-result').forEach(el => { 85 - el.addEventListener('click', function() { 86 - input.value = this.dataset.handle; 67 + // Create container div 68 + const resultDiv = document.createElement('div'); 69 + resultDiv.className = 'handle-result px-3 py-2 hover:bg-gray-100 cursor-pointer flex items-center gap-2'; 70 + resultDiv.setAttribute('data-handle', actor.handle); 71 + 72 + // Create avatar image 73 + const img = document.createElement('img'); 74 + // Validate URL scheme to prevent javascript: URLs 75 + if (avatarUrl && (avatarUrl.startsWith('https://') || avatarUrl.startsWith('/static/'))) { 76 + img.src = avatarUrl; 77 + } else { 78 + img.src = '/static/icon-placeholder.svg'; 79 + } 80 + img.alt = ''; // Empty alt for decorative images 81 + img.width = 32; 82 + img.height = 32; 83 + img.className = 'w-6 h-6 rounded-full object-cover flex-shrink-0'; 84 + img.onerror = function() { this.src = '/static/icon-placeholder.svg'; }; 85 + 86 + // Create text container 87 + const textContainer = document.createElement('div'); 88 + textContainer.className = 'flex-1 min-w-0'; 89 + 90 + // Create display name element 91 + const nameDiv = document.createElement('div'); 92 + nameDiv.className = 'font-medium text-sm text-gray-900 truncate'; 93 + nameDiv.textContent = displayName; // textContent auto-escapes 94 + 95 + // Create handle element 96 + const handleDiv = document.createElement('div'); 97 + handleDiv.className = 'text-xs text-gray-500 truncate'; 98 + handleDiv.textContent = '@' + actor.handle; // textContent auto-escapes 99 + 100 + // Assemble the elements 101 + textContainer.appendChild(nameDiv); 102 + textContainer.appendChild(handleDiv); 103 + resultDiv.appendChild(img); 104 + resultDiv.appendChild(textContainer); 105 + 106 + // Add click handler 107 + resultDiv.addEventListener('click', function() { 108 + input.value = actor.handle; // Use the actual handle from data, not DOM 87 109 results.classList.add('hidden'); 88 110 results.innerHTML = ''; 89 111 }); 112 + 113 + results.appendChild(resultDiv); 90 114 }); 115 + 116 + results.classList.remove('hidden'); 91 117 } catch (error) { 92 118 if (error.name !== 'AbortError') { 93 119 console.error('Error searching actors:', error);