## 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 `