diff --git a/CHANGELOG.md b/CHANGELOG.md index 6718899..0462fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ ## Changelog +### v0.23.0 — CSRF Protection (2026-02-21) + +**CSRF (Cross-Site Request Forgery) protection on all browser-facing POST endpoints — controller and hub.** + +**Controller changes:** + +- New `internal/web/csrf.go`: `CsrfProtect` HTTP middleware validates CSRF tokens on all state-mutating requests (POST/DELETE/PATCH). + - Reads token from `_csrf` form field or `X-CSRF-Token` request header. + - Exempt paths: `Authorization: Bearer` requests (selfupdate, config/apply hub→controller calls) — browsers cannot auto-send Bearer headers, so no CSRF risk. + - Auth-disabled mode (no password set): CSRF check is skipped entirely. + - On rejection: JSON error for `/api/` paths, HTTP 403 text for page routes. +- `internal/web/auth.go`: `session` struct gains a `csrfToken string` field. `createSession()` generates a second 32-byte random CSRF token alongside the session token. New `csrfTokenForSession(sessionToken)` method returns the CSRF token for a given session. +- `internal/web/server.go`: New `executeTemplate(w, r, name, data)` wrapper auto-injects `CSRFField` (`template.HTML` hidden input) and `CSRFToken` (raw string) into every page render data map. +- `cmd/controller/main.go`: All route registrations wrapped with `webServer.CsrfProtect(...)` middleware. Version bumped to `v0.23.0`. +- All handlers (`handlers.go`, `storage_handlers.go`, `handler_restore.go`): Switched from `s.render(w, ...)` to `s.executeTemplate(w, r, ...)`. +- All templates updated: + - `layout.html`: Added `` and inline `csrfHeaders()` JS helper (returns `{'X-CSRF-Token': ...}`) in `
` (before page-specific scripts). Updated 4 fetch POST/DELETE calls. + - `settings.html`: Added `{{$.CSRFField}}` to 5 forms inside `{{range .StoragePaths}}` (must use `$` for outer scope inside range). Added `{{.CSRFField}}` to 3 page-level forms. Inline-label form uses `document.querySelector('meta[name="csrf-token"]').content`. Updated 5 fetch calls. + - `deploy.html`: Added `{{.CSRFField}}` to cross-backup form. Updated 3 fetch calls. + - `backups.html`: Updated 3 fetch calls. Dynamically-created restore form injects `_csrf` from meta tag. + - `storage_init.html`, `storage_attach.html`, `migrate.html`, `migrate_drive.html`, `app_info.html`, `restore.html`: All fetch calls updated. + - `storage_attach.html`: Replaced `navigator.sendBeacon()` with `fetch(..., {keepalive: true})` — `sendBeacon` cannot send custom headers, making CSRF impossible. + +**Hub changes (v0.3.8):** + +- `internal/web/server.go`: Replaced insecure literal `hub_session=authenticated` cookie with proper server-side session map. + - New `hubSession` struct with `csrfToken string` and `expiresAt time.Time`. + - `sessions map[string]*hubSession` + `sessionsMu sync.RWMutex` on `Server` struct. + - `handleLogin`: Generates cryptographically random 64-char hex session token + 64-char hex CSRF token. Cookie gains `SameSite=Lax` and `Secure` (when TLS) attributes. Session expires after 7 days. + - `RequireAuth`: Validates session token against map (constant-time compare), redirects to `/login` on failure. + - `CleanupSessions(ctx)`: Goroutine that purges expired sessions every hour. + - CSRF validation block at top of `ServeHTTP`: checks `X-CSRF-Token` header or `_csrf` form field on POST/DELETE/PATCH. Skips when no session cookie (Basic Auth / API path). + - `csrfToken(r)`, `csrfField(r)` helpers for template data injection. +- `internal/web/configs.go`: Added `html/template` import. All template render calls pass `CSRFField template.HTML` and/or `CSRFToken string`. `renderConfigForm` gains `r *http.Request` parameter. +- Templates updated: + - `config_form.html`: Added `{{.CSRFField}}` inside the ` +``` + +Templates to update (search for `method="POST"` or `method="post"` in all template files): + +| # | Template | Form action(s) | +|---|----------|----------------| +| 1 | `settings.html` | `/settings/password`, `/settings/notifications`, `/settings/notifications/test`, `/settings/storage/add`, `/settings/storage/remove`, `/settings/storage/default`, `/settings/storage/schedulable`, `/settings/storage/label` | +| 2 | `deploy.html` | Cross-backup form (if any form POST exists) | +| 3 | `backups.html` | `/backup/restore` form, cross-backup save forms | +| 4 | `init_disk.html` | Storage init wizard (if form-based) | +| 5 | `attach_disk.html` | Attach wizard (if form-based) | +| 6 | `restore.html` | DR restore (if form-based) | + +**Note:** Many operations on these pages use JavaScript `fetch()` instead of HTML form submissions. For those, see section 1.6. + +### 1.6 Add CSRF token to JavaScript fetch() calls + +For all JavaScript code that makes POST/DELETE requests via `fetch()`, include the CSRF token in the `X-CSRF-Token` header. + +**Step A: Embed the token in a meta tag in the base layout.** + +In the template that defines the `` section (likely within `{{define "head"}}` or the shared HTML header), add: + +```html + +``` + +**Step B: Create a helper function in the shared JS.** + +At the top of the `