v0.23.0 — CSRF protection on all browser-facing POST endpoints

Controller:
- internal/web/csrf.go (new): CsrfProtect middleware, csrfToken/csrfField helpers
- auth.go: per-session CSRF token (csrfToken field, csrfTokenForSession method)
- server.go: executeTemplate wrapper auto-injects CSRFField+CSRFToken
- main.go: wire CsrfProtect on all routes; bump to v0.23.0
- handlers.go, storage_handlers.go, handler_restore.go: executeTemplate
- All templates: CSRFField in forms, meta csrf-token, csrfHeaders() JS helper,
  fetch calls updated; sendBeacon→fetch+keepalive in storage_attach.html

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 16:38:56 +01:00
parent ade01470d0
commit 02650e3202
20 changed files with 1143 additions and 75 deletions
+855
View File
@@ -0,0 +1,855 @@
# TASK: CSRF Protection on POST Endpoints
**Controller:** v0.22.3 → v0.23.0
**Hub:** v0.3.7 → v0.3.8
## Overview
Add CSRF (Cross-Site Request Forgery) protection to all browser-facing POST endpoints in both the controller and the hub. Currently, any page on the internet can craft a form that POSTs to the controller/hub if the user has a valid session cookie — no origin verification exists.
**Approach: Synchronizer Token Pattern** (per-session CSRF token stored server-side, embedded in forms, validated on POST). This matches the existing setup wizard implementation in `controller/internal/setup/csrf.go` but is more secure: tokens are tied to server-side sessions rather than using the double-submit cookie pattern.
**Key principle:** Bearer-token-authenticated API endpoints (selfupdate, config/apply) do NOT need CSRF protection — browsers never auto-send `Authorization` headers. Only cookie/session-authenticated POST endpoints need protection.
---
## Security Audit Summary
### Controller (current state)
| Item | Status | Notes |
|------|--------|-------|
| Session cookie HttpOnly | OK | `HttpOnly: true` |
| Session cookie SameSite | PARTIAL | `SameSite=Lax` (good defense-in-depth but not sufficient alone) |
| Session cookie Secure | OK | Set when TLS detected |
| Random session tokens | OK | 64-char hex (32 bytes) |
| CSRF on web form POSTs | MISSING | **None — vulnerable** |
| CSRF on JS-driven API POSTs | MISSING | **None — vulnerable** |
| Bearer-only endpoints exempt | n/a | selfupdate + config endpoints accept Bearer OR session |
### Hub (current state — MORE CRITICAL)
| Item | Status | Notes |
|------|--------|-------|
| Session cookie HttpOnly | OK | `HttpOnly: true` |
| Session cookie SameSite | MISSING | **Not set** (browser default = Lax in modern browsers, but should be explicit) |
| Session cookie Secure | MISSING | **Not set** |
| Random session tokens | MISSING | **Cookie value is literal string `"authenticated"`** — any cookie injection = full session |
| CSRF on web form POSTs | MISSING | **None — vulnerable** |
| CSRF on JS-driven POSTs | MISSING | **None — vulnerable** |
**Hub session model is fundamentally weak:** The `hub_session=authenticated` cookie contains no randomness. This means:
1. Any XSS or subdomain-based cookie injection can forge a session
2. There is no way to invalidate individual sessions
3. Combined with no CSRF = highly vulnerable
This task fixes both the CSRF gap AND the hub session model.
---
## Part 1: Controller CSRF (v0.23.0)
### Endpoint Map: All State-Changing (POST/DELETE) Endpoints
#### Web Form POSTs (need CSRF token in hidden field)
| # | Method | Path | Handler | Auth |
|---|--------|------|---------|------|
| 1 | POST | `/settings/password` | `handlePasswordChange` | Session |
| 2 | POST | `/settings/notifications` | `handleNotificationSettings` | Session |
| 3 | POST | `/settings/notifications/test` | `handleTestNotification` | Session |
| 4 | POST | `/settings/storage/add` | `handleStorageAdd` | Session |
| 5 | POST | `/settings/storage/remove` | `handleStorageRemove` | Session |
| 6 | POST | `/settings/storage/default` | `handleStorageDefault` | Session |
| 7 | POST | `/settings/storage/schedulable` | `handleStorageSchedulable` | Session |
| 8 | POST | `/settings/storage/label` | `handleStorageLabel` | Session |
| 9 | POST | `/settings/cross-backup/{name}` | `handleCrossBackupSave` | Session |
| 10 | POST | `/backup/restore` | `handleRestore` | Session |
#### JSON API POSTs called via `fetch()` from UI pages (need X-CSRF-Token header)
| # | Method | Path | Handler | Notes |
|---|--------|------|---------|-------|
| 11 | POST | `/api/stacks/{name}/deploy` | `handleDeploy` | Deploy page JS |
| 12 | POST | `/api/stacks/{name}/start` | `handleStart` | Dashboard buttons |
| 13 | POST | `/api/stacks/{name}/stop` | `handleStop` | Dashboard buttons |
| 14 | POST | `/api/stacks/{name}/restart` | `handleRestart` | Dashboard buttons |
| 15 | POST | `/api/stacks/{name}/update` | `handleUpdate` | Dashboard buttons |
| 16 | POST | `/api/stacks/{name}/optional-config` | `handleOptionalConfig` | App info page |
| 17 | POST | `/api/stacks/{name}/remove` | `handleRemove` | Remove modal |
| 18 | DELETE | `/api/stacks/{name}` | `handleDelete` | Delete modal |
| 19 | POST | `/api/stacks/{name}/cross-backup` | `handleCrossBackupAPI` | Deploy page |
| 20 | POST | `/api/stacks/{name}/cross-backup/run` | `handleCrossBackupRun` | Backup page |
| 21 | POST | `/api/sync` | `handleSync` | Dashboard button |
| 22 | POST | `/api/backup/run` | `handleBackupRun` | Backup page |
| 23 | POST | `/api/backup/cross-drive/run-all` | `handleCrossDriveRunAll` | Backup page |
| 24 | POST | `/api/stacks/rescan` | `handleRescan` | Internal |
| 25 | POST | `/api/storage/scan` | `handleStorageScan` | Init wizard |
| 26 | POST | `/api/storage/init` | `handleStorageInit` | Init wizard |
| 27 | POST | `/api/storage/attach/mount-raw` | `handleAttachMountRaw` | Attach wizard |
| 28 | POST | `/api/storage/attach/mkdir` | `handleAttachMkdir` | Attach wizard |
| 29 | POST | `/api/storage/attach` | `handleAttachFinalize` | Attach wizard |
| 30 | POST | `/api/storage/attach/cancel` | `handleAttachCancel` | Attach wizard |
| 31 | POST | `/api/storage/migrate` | `handleMigrate` | Migration page |
| 32 | POST | `/api/storage/stale-cleanup` | `handleStaleCleanup` | Deploy page |
| 33 | POST | `/api/storage/disconnect` | `handleDisconnect` | Settings |
| 34 | POST | `/api/storage/reconnect` | `handleReconnect` | Settings |
| 35 | POST | `/api/storage/restart-apps` | `handleRestartApps` | Settings |
| 36 | POST | `/api/storage/migrate-drive` | `handleMigrateDrive` | Drive migration |
| 37 | POST | `/api/storage/decommission/remove` | `handleDecommissionRemove` | Settings |
| 38 | POST | `/api/assets/sync` | `handleAssetSync` | Internal |
| 39 | POST | `/api/restore/all` | `handleRestoreAll` | DR page |
| 40 | POST | `/api/restore/skip` | `handleRestoreSkip` | DR page |
#### Bearer-token API POSTs (CSRF EXEMPT — not browser-initiated)
| # | Method | Path | Notes |
|---|--------|------|-------|
| — | POST | `/api/selfupdate/check` | Accepts Bearer token (hub/scripts) |
| — | POST | `/api/selfupdate/update` | Accepts Bearer token (hub/scripts) |
| — | POST | `/api/config/apply` | Accepts Bearer token (hub push) |
These three endpoints accept **both** session auth and Bearer auth. When accessed with a Bearer token, CSRF is not needed. When accessed via session cookie from the UI, they should require CSRF. The middleware handles this: if `Authorization: Bearer` header is present and valid, skip CSRF check.
#### GET-only / No-auth endpoints (no CSRF needed)
| Method | Path | Notes |
|--------|------|-------|
| GET | `/api/health` | No auth, read-only |
| GET | `/api/stacks`, `/api/stacks/{name}`, etc. | Read-only |
| GET | `/api/backup/status`, `/api/backup/snapshots` | Read-only |
| GET | `/api/metrics/*`, `/api/system/info` | Read-only |
| GET | `/api/storage/*` (browse, status) | Read-only |
| GET | `/api/selfupdate/status` | Read-only |
| GET | `/api/config`, `/api/config/hash` | Read-only |
| GET | `/api/assets/status` | Read-only |
---
### 1.1 Add CSRF token to session struct
**File: `controller/internal/web/auth.go`**
Change the session struct to include a CSRF token:
```go
type session struct {
expiresAt time.Time
csrfToken string
}
```
Update `createSession()` to generate a CSRF token alongside the session token:
```go
func (s *Server) createSession() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
csrfB := make([]byte, 32)
_, _ = rand.Read(csrfB)
csrfToken := hex.EncodeToString(csrfB)
s.sessionsMu.Lock()
s.sessions[token] = &session{
expiresAt: time.Now().Add(sessionMaxAge),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
return token
}
```
Add a method to retrieve the CSRF token for a session:
```go
// csrfTokenForSession returns the CSRF token for the given session cookie value.
// Returns "" if the session is invalid or expired.
func (s *Server) csrfTokenForSession(sessionToken string) string {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
sess, ok := s.sessions[sessionToken]
if !ok || time.Now().After(sess.expiresAt) {
return ""
}
return sess.csrfToken
}
```
### 1.2 Create CSRF middleware
**File: `controller/internal/web/csrf.go`** (new file)
```go
package web
import (
"crypto/subtle"
"fmt"
"html/template"
"net/http"
"strings"
)
const csrfFormField = "_csrf"
const csrfHeaderName = "X-CSRF-Token"
// csrfProtect validates CSRF tokens on unsafe HTTP methods (POST, PUT, DELETE, PATCH).
// Safe methods (GET, HEAD, OPTIONS) pass through — the token is made available
// to templates via the Server.csrfToken() helper.
//
// Exempt: requests with a valid Authorization: Bearer header (API key auth).
func (s *Server) csrfProtect(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Safe methods: no CSRF check needed
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
next.ServeHTTP(w, r)
return
}
// Skip CSRF if auth is disabled (no password set = open access)
if !s.authEnabled() {
next.ServeHTTP(w, r)
return
}
// Skip CSRF for Bearer-token authenticated requests.
// These endpoints also accept session auth, but when a Bearer token
// is present, the request is from a script/hub, not a browser.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
next.ServeHTTP(w, r)
return
}
// Get the session's CSRF token
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
s.csrfReject(w, r, "no session")
return
}
expected := s.csrfTokenForSession(cookie.Value)
if expected == "" {
s.csrfReject(w, r, "invalid session")
return
}
// Check form field first, then header (for fetch/AJAX)
submitted := r.FormValue(csrfFormField)
if submitted == "" {
submitted = r.Header.Get(csrfHeaderName)
}
if submitted == "" || subtle.ConstantTimeCompare([]byte(submitted), []byte(expected)) != 1 {
s.csrfReject(w, r, "token mismatch")
return
}
next.ServeHTTP(w, r)
})
}
// csrfReject sends a 403 response. For API requests returns JSON, for web requests returns HTML.
func (s *Server) csrfReject(w http.ResponseWriter, r *http.Request, reason string) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s (%s)", r.Method, r.URL.Path, r.RemoteAddr, reason)
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, `{"ok":false,"error":"CSRF token missing or invalid"}`)
return
}
http.Error(w, "CSRF token missing or invalid. Please reload the page and try again.", http.StatusForbidden)
}
// csrfToken returns the CSRF token for the current request's session.
// Call from handlers to embed in templates or return to JS.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return ""
}
return s.csrfTokenForSession(cookie.Value)
}
// csrfField returns an HTML hidden input for embedding in forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
token := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="` + csrfFormField + `" value="` + template.HTMLEscapeString(token) + `">`)
}
```
### 1.3 Wire CSRF middleware into the server
**File: `controller/internal/web/server.go`**
The controller uses a custom router in `ServeHTTP`. The CSRF middleware must wrap the handler AFTER auth but protect all POST routes. Find where `RequireAuth` wraps the server's handler and add `csrfProtect` inside it.
In `server.go`, locate the HTTP server setup. The pattern is typically:
```go
handler := s.RequireAuth(s)
```
Change to:
```go
handler := s.RequireAuth(s.csrfProtect(s))
```
This means: RequireAuth runs first (verifies session), then csrfProtect runs (verifies CSRF token on POST), then the actual handler runs.
**Important:** Check the exact wiring in `server.go`. The auth middleware likely wraps the mux in `Start()` or `ListenAndServe()`. Find that location and insert `csrfProtect` between auth and handler. If the `RequireAuth` is applied in `main.go`, adjust there instead.
### 1.4 Add CSRF token to all template data
**File: `controller/internal/web/handlers.go`** (and `storage_handlers.go`, `handler_restore.go`)
Every handler that renders an HTML page must include the CSRF token in its template data. The recommended approach:
**Add `CSRFField` and `CSRFToken` to every template data map.** In each handler that renders a page, add these two fields:
```go
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
```
Alternatively, create an `executeTemplate` wrapper method that auto-injects these:
```go
// executeTemplate renders a template with per-request CSRF data injected.
func (s *Server) executeTemplate(w http.ResponseWriter, r *http.Request, name string, data map[string]interface{}) {
if data == nil {
data = make(map[string]interface{})
}
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
```
Then replace all `s.tmpl.ExecuteTemplate(w, "templateName", data)` calls with `s.executeTemplate(w, r, "templateName", data)`.
**Handlers that render pages (all need CSRFField/CSRFToken):**
- `handleDashboard` → dashboard template
- `handleStacks` → stacks template (if separate from dashboard)
- `handleDeploy` → deploy template
- `handleBackups` → backups template
- `handleMonitoring` → monitoring template
- `handleSettings` → settings template
- `handleAppInfo` → app_info template
- `handleLogs` → logs template
- `handleMigrate` → migrate template
- `handleMigrateDrive` → migrate_drive template
- `handleStorageInit` → init_disk template
- `handleStorageAttach` → attach_disk template
- `handleRestorePage` → restore template
- `renderLogin` → login template (**no CSRF needed** — no session yet)
### 1.5 Add hidden field to all HTML form templates
**File: `controller/internal/web/templates/*.html`**
For every `<form method="POST" ...>` in every template, add the CSRF hidden field right after the `<form>` tag:
```html
<form method="POST" action="/settings/password">
{{.CSRFField}}
<!-- existing form fields -->
</form>
```
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 `<head>` section (likely within `{{define "head"}}` or the shared HTML header), add:
```html
<meta name="csrf-token" content="{{.CSRFToken}}">
```
**Step B: Create a helper function in the shared JS.**
At the top of the `<script>` block in the layout or in each template, add a reusable helper:
```javascript
function csrfHeaders() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content} : {};
}
```
**Step C: Update every `fetch()` call to include CSRF headers.**
Example — before:
```javascript
fetch('/api/stacks/' + name + '/start', {method: 'POST'})
```
After:
```javascript
fetch('/api/stacks/' + name + '/start', {method: 'POST', headers: csrfHeaders()})
```
For fetch calls that already have headers (e.g., `Content-Type: application/json`), merge:
```javascript
fetch('/api/stacks/' + name + '/deploy', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify(data)
})
```
**Comprehensive list of templates with fetch() POST calls to update:**
Search all `.html` template files for `fetch(` with `method:` containing `POST` or `DELETE`:
| Template | fetch() calls to update |
|----------|------------------------|
| `dashboard.html` | Stack start/stop/restart/update buttons |
| `deploy.html` | Deploy, optional-config, cross-backup save/run, stale-cleanup |
| `stacks.html` | (if any action buttons) |
| `backups.html` | Backup run, cross-drive run-all, cross-backup run |
| `settings.html` | Storage disconnect/reconnect/restart-apps, self-update check/update, storage label/schedulable (if AJAX) |
| `init_disk.html` | Storage scan, init, poll status |
| `attach_disk.html` | Mount-raw, mkdir, attach, cancel, poll status |
| `migrate.html` | Migrate start, poll status |
| `migrate_drive.html` | Drive migration start, poll status |
| `app_info.html` | Optional config save |
| `restore.html` | Restore all, skip |
### 1.7 Files changed summary (Controller)
| # | File | Action | Description |
|---|------|--------|-------------|
| 1 | `internal/web/auth.go` | Edit | Add `csrfToken` field to session struct, generate in `createSession()`, add `csrfTokenForSession()` |
| 2 | `internal/web/csrf.go` | **New** | CSRF middleware, helpers (`csrfProtect`, `csrfReject`, `csrfToken`, `csrfField`) |
| 3 | `internal/web/server.go` | Edit | Wire `csrfProtect` middleware after `RequireAuth`, optionally add `executeTemplate` wrapper |
| 4 | `internal/web/handlers.go` | Edit | Pass CSRFField/CSRFToken to every page template render |
| 5 | `internal/web/storage_handlers.go` | Edit | Pass CSRFField/CSRFToken to storage page renders |
| 6 | `internal/web/handler_restore.go` | Edit | Pass CSRFField/CSRFToken to restore page render |
| 7 | `internal/web/templates/settings.html` | Edit | Add `{{.CSRFField}}` to all `<form>` tags + CSRF meta tag + update JS |
| 8 | `internal/web/templates/deploy.html` | Edit | CSRF meta tag + update JS fetch calls |
| 9 | `internal/web/templates/backups.html` | Edit | Add `{{.CSRFField}}` to restore form + CSRF meta tag + update JS |
| 10 | `internal/web/templates/dashboard.html` | Edit | CSRF meta tag + update JS fetch calls |
| 11 | `internal/web/templates/init_disk.html` | Edit | CSRF meta tag + update JS fetch calls |
| 12 | `internal/web/templates/attach_disk.html` | Edit | CSRF meta tag + update JS fetch calls |
| 13 | `internal/web/templates/migrate.html` | Edit | CSRF meta tag + update JS fetch calls |
| 14 | `internal/web/templates/migrate_drive.html` | Edit | CSRF meta tag + update JS fetch calls |
| 15 | `internal/web/templates/app_info.html` | Edit | CSRF meta tag + update JS fetch calls |
| 16 | `internal/web/templates/restore.html` | Edit | CSRF meta tag + update JS fetch calls |
| 17 | `internal/web/templates/monitoring.html` | Edit | CSRF meta tag (if any POST actions exist) |
| 18 | `cmd/controller/main.go` | Edit | Bump Version to "0.23.0" |
---
## Part 2: Hub CSRF (v0.3.8)
### Endpoint Map: All State-Changing (POST) Endpoints
#### Web Form/JS POSTs (need CSRF — session-authenticated)
| # | Method | Path | Handler | Type |
|---|--------|------|---------|------|
| 1 | POST | `/configs/new` | `handleConfigCreate` | Form submit |
| 2 | POST | `/configs/{id}/edit` | `handleConfigUpdate` | Form submit |
| 3 | POST | `/configs/{id}/delete` | `handleConfigDelete` | JS fetch |
| 4 | POST | `/configs/{id}/regen-password` | `handleConfigRegenPassword` | JS fetch |
| 5 | POST | `/customers/{id}/trigger-update` | `handleTriggerUpdate` | JS fetch |
| 6 | POST | `/customers/{id}/block` | `handleBlockCustomer` | JS fetch |
| 7 | POST | `/customers/{id}/unblock` | `handleUnblockCustomer` | JS fetch |
| 8 | POST | `/customers/{id}/push-config` | `handlePushConfig` | JS fetch |
| 9 | POST | `/customers/{id}/pull-config` | `handlePullConfig` | JS fetch |
| 10 | POST | `/customers/{id}/create-config` | `handleCreateConfigFromReport` | JS fetch |
#### API POSTs (CSRF EXEMPT — Bearer-token authenticated, not browser-initiated)
| # | Method | Path | Notes |
|---|--------|------|-------|
| — | POST | `/api/v1/report` | Controller pushes report (Bearer) |
| — | POST | `/api/v1/event` | Controller pushes event (Bearer) |
| — | POST | `/api/v1/notify` | Legacy notification (Bearer) |
| — | POST | `/api/v1/preferences` | Preference sync (Bearer) |
| — | POST | `/api/v1/infra-backup` | Infra backup push (Bearer) |
These all live under `/api/v1/` and use Bearer token auth exclusively. The CSRF middleware should skip all paths starting with `/api/v1/`.
---
### 2.1 Fix hub session model (PREREQUISITE)
**File: `hub/internal/web/server.go`**
The hub currently uses `hub_session=authenticated` as the cookie value — a literal string, not a random token. This must be fixed before CSRF can work (CSRF tokens must be tied to unpredictable sessions).
**A) Add session storage to the Server struct:**
```go
type Server struct {
// ... existing fields ...
sessions map[string]*hubSession
sessionsMu sync.RWMutex
}
type hubSession struct {
expiresAt time.Time
csrfToken string
}
```
Initialize in `New()`:
```go
sessions: make(map[string]*hubSession),
```
**B) Update `handleLogin` to create random session tokens:**
```go
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
password := r.FormValue("password")
if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil {
// Generate random session token
b := make([]byte, 32)
_, _ = rand.Read(b)
sessionToken := hex.EncodeToString(b)
// Generate CSRF token
cb := make([]byte, 32)
_, _ = rand.Read(cb)
csrfToken := hex.EncodeToString(cb)
s.sessionsMu.Lock()
s.sessions[sessionToken] = &hubSession{
expiresAt: time.Now().Add(7 * 24 * time.Hour),
csrfToken: csrfToken,
}
s.sessionsMu.Unlock()
isSecure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
http.SetCookie(w, &http.Cookie{
Name: "hub_session",
Value: sessionToken,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: isSecure,
MaxAge: 86400 * 7,
})
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid password", http.StatusUnauthorized)
return
}
// Render login page (keep existing minimal HTML or use template)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
}
```
**C) Update `RequireAuth` to validate random tokens:**
Replace the cookie check:
```go
// OLD:
if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" {
// NEW:
if cookie, err := r.Cookie("hub_session"); err == nil {
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if ok && time.Now().Before(sess.expiresAt) {
next.ServeHTTP(w, r)
return
}
}
```
**D) Add session cleanup goroutine** (start from `main.go`):
```go
func (s *Server) CleanupSessions(ctx context.Context) {
ticker := time.NewTicker(15 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sessionsMu.Lock()
now := time.Now()
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
}
}
s.sessionsMu.Unlock()
}
}
}
```
Start it in `main.go`: `go webServer.CleanupSessions(ctx)`
**E) Add imports** to `server.go`: `"crypto/rand"`, `"encoding/hex"`, `"sync"`
### 2.2 Add CSRF validation to hub
**File: `hub/internal/web/server.go`**
The simplest approach for the hub is to add CSRF validation directly in `ServeHTTP` at the top, before the routing switch. This avoids middleware composition complexity:
```go
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// CSRF protection for all POST/PUT/DELETE requests (web routes only)
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
if path != "/login" && s.passwordHash != "" {
if !s.validateCSRF(r) {
s.logger.Printf("[WARN] CSRF rejected: %s %s from %s", r.Method, path, r.RemoteAddr)
http.Error(w, "CSRF token missing or invalid. Please reload the page.", http.StatusForbidden)
return
}
}
}
// ... existing switch/case routing ...
}
```
Add a `validateCSRF` helper method:
```go
func (s *Server) validateCSRF(r *http.Request) bool {
// If no session cookie present, this is a Basic Auth or non-browser request — skip CSRF
cookie, err := r.Cookie("hub_session")
if err != nil {
return true // No session cookie = not a browser CSRF attack
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return false
}
submitted := r.FormValue("_csrf")
if submitted == "" {
submitted = r.Header.Get("X-CSRF-Token")
}
return submitted != "" && subtle.ConstantTimeCompare([]byte(submitted), []byte(sess.csrfToken)) == 1
}
```
Add helper methods for templates:
```go
// csrfToken returns the CSRF token for the current session.
func (s *Server) csrfToken(r *http.Request) string {
cookie, err := r.Cookie("hub_session")
if err != nil {
return ""
}
s.sessionsMu.RLock()
sess, ok := s.sessions[cookie.Value]
s.sessionsMu.RUnlock()
if !ok {
return ""
}
return sess.csrfToken
}
// csrfField returns an HTML hidden input for forms.
func (s *Server) csrfField(r *http.Request) template.HTML {
tok := s.csrfToken(r)
return template.HTML(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
}
```
Add import: `"crypto/subtle"`
### 2.3 Add CSRF token to hub templates
**File: `hub/internal/web/templates/*.html`**
**A) Config form (`config_form.html`)** — used for both create and edit:
Add `{{.CSRFField}}` after each `<form>` opening tag.
**B) Customer unified page (`customer_unified.html`)** — has JS fetch() for trigger-update, block, unblock, push-config, pull-config, create-config:
Add CSRF meta tag in head:
```html
<meta name="csrf-token" content="{{.CSRFToken}}">
```
Update all `fetch()` calls to include `X-CSRF-Token` header. Add helper:
```javascript
function csrfHeaders() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content} : {};
}
```
**C) Configs list (`configs.html`)** — has delete button with JS fetch():
Add CSRF meta tag + update delete/regen-password fetch calls.
**D) Dashboard (`dashboard.html`)** — likely read-only, but check for any POST actions. Add meta tag if needed.
**Templates to update:**
| # | Template | Changes |
|---|----------|---------|
| 1 | `config_form.html` | Add `{{.CSRFField}}` inside `<form>` |
| 2 | `customer_unified.html` | Add meta tag + update all fetch() POST calls |
| 3 | `configs.html` | Add meta tag + update delete/regen-password fetch() calls |
| 4 | `dashboard.html` | Check for POST actions, add meta tag if needed |
**E) Update all handler functions** to pass `CSRFField` and `CSRFToken` in template data:
For every handler that renders a template, add to the data struct/map:
```go
CSRFField: s.csrfField(r),
CSRFToken: s.csrfToken(r),
```
Hub handlers that render templates:
- `handleDashboard` — pass CSRFToken (if any POST actions on page)
- `handleCustomerUnified` — pass CSRFField + CSRFToken
- `handleConfigList` — pass CSRFToken
- `handleConfigNewForm` — pass CSRFField + CSRFToken
- `handleConfigEditForm` — pass CSRFField + CSRFToken
### 2.4 Hub Basic Auth note
The hub allows HTTP Basic Auth as an alternative to session cookies. The `validateCSRF` method above handles this correctly: if no session cookie is present (pure Basic Auth request), CSRF validation is skipped. This is safe because:
1. Cross-site forms cannot inject Basic Auth headers
2. Browser-cached Basic Auth is only auto-sent to the same origin
3. The SameSite=Lax cookie prevents cross-site cookie-based attacks
### 2.5 Files changed summary (Hub)
| # | File | Action | Description |
|---|------|--------|-------------|
| 1 | `internal/web/server.go` | Edit | Add session map + mutex + hubSession struct, random session tokens, SameSite=Lax, Secure flag, session cleanup, CSRF validation in ServeHTTP, csrfToken/csrfField/validateCSRF helpers |
| 2 | `internal/web/configs.go` | Edit | Pass CSRFField/CSRFToken to template data in all config handlers |
| 3 | `internal/web/templates/config_form.html` | Edit | Add `{{.CSRFField}}` to form |
| 4 | `internal/web/templates/customer_unified.html` | Edit | Add CSRF meta tag + update all JS fetch() calls |
| 5 | `internal/web/templates/configs.html` | Edit | Add CSRF meta tag + update JS fetch() calls |
| 6 | `internal/web/templates/dashboard.html` | Edit | Add CSRF meta tag (if any POST actions) |
| 7 | `cmd/hub/main.go` | Edit | Start session cleanup goroutine |
---
## Implementation Order
1. **Controller first** (v0.23.0) — larger surface area, more endpoints to protect
1. `auth.go` — Add csrfToken to session struct
2. `csrf.go` — New file with CSRF middleware + helpers
3. `server.go` — Wire middleware, add executeTemplate or inject CSRFField/CSRFToken
4. `handlers.go` — Pass CSRF data to all page renders
5. `storage_handlers.go` — Pass CSRF data to storage page renders
6. `handler_restore.go` — Pass CSRF data to restore page render
7. Templates — Add `{{.CSRFField}}` to forms + meta tags + update JS
8. Test: attempt POST without token → 403, with token → success
2. **Hub second** (v0.3.8) — fewer endpoints, session model fix is critical
1. `server.go` — Random session tokens, SameSite, Secure, cleanup
2. CSRF validation + helpers in server.go
3. `configs.go` — Pass CSRF data to template renders
4. Templates — Add hidden fields + meta tags + update JS
5. `main.go` — Start session cleanup goroutine
6. Test: attempt POST without token → 403, with token → success
---
## Testing Checklist
### Controller
- [ ] Login still works (no CSRF on login — no session yet)
- [ ] All form submissions work with CSRF token (settings, notifications, storage, backup restore)
- [ ] All JS-driven actions work (deploy, start/stop/restart, update, remove, delete, sync, backup, storage wizards, migration)
- [ ] Bearer-token requests still work without CSRF (selfupdate, config/apply from hub)
- [ ] Cross-site POST without token returns 403
- [ ] Cross-site fetch() without X-CSRF-Token returns 403
- [ ] After session expires and re-login, new CSRF token works
- [ ] Open-access mode (no password) still works — CSRF skipped
### Hub
- [ ] Login creates random session token (not literal "authenticated")
- [ ] Session cookie has SameSite=Lax + Secure (when TLS)
- [ ] All form submissions work (config create/edit)
- [ ] All JS-driven actions work (delete, regen-password, trigger-update, block/unblock, push/pull config)
- [ ] API endpoints still work with Bearer token (report push, event push, etc.)
- [ ] Cross-site POST without token returns 403
- [ ] Session cleanup removes expired sessions
- [ ] Basic Auth requests (no session cookie) bypass CSRF correctly
---
## Build & Deploy
### Controller (v0.23.0)
```bash
SSH=/c/Windows/System32/OpenSSH/ssh.exe
# 1. Commit and push
cd /e/git/deploy-felhom-compose
git add -A && git commit -m "feat: CSRF protection on all POST endpoints (v0.23.0)" && git push
# 2. Build
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh v0.23.0 --push"
# 3. Deploy
$SSH kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && sudo docker pull gitea.dooplex.hu/admin/felhom-controller:v0.23.0 && sudo sed -i 's|image: gitea.dooplex.hu/admin/felhom-controller:.*|image: gitea.dooplex.hu/admin/felhom-controller:v0.23.0|' docker-compose.yml && sudo docker compose up -d"
# 4. Verify
$SSH kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
```
### Hub (v0.3.8)
```bash
# 1. Commit and push
cd /e/git/felhom.eu
git add -A && git commit -m "feat: CSRF protection + random session tokens (v0.3.8)" && git push
# 2. Build
$SSH kisfenyo@192.168.0.180 "cd ~/build/felhom-hub && git checkout -- hub/go.mod && git pull && cd ~/build/felhom-hub && ./build.sh v0.3.8 --push"
# 3. Deploy via ArgoCD — update manifests/hub.yaml image tag to v0.3.8, then:
$SSH kisfenyo@192.168.0.180 "cd ~/git/felhom.eu && git pull && kubectl apply -f manifests/hub.yaml"
# 4. Verify
$SSH kisfenyo@192.168.0.180 "kubectl get pods -n felhom-system -l app=hub && kubectl logs -n felhom-system -l app=hub --tail 10"
```
**Deploy order:** Either order works. Both components are backward-compatible — CSRF tokens are only checked on the component's own forms/JS, not cross-component communication (which uses Bearer tokens).