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>
34 KiB
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:
- Any XSS or subdomain-based cookie injection can forge a session
- There is no way to invalidate individual sessions
- 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:
type session struct {
expiresAt time.Time
csrfToken string
}
Update createSession() to generate a CSRF token alongside the session token:
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:
// 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)
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:
handler := s.RequireAuth(s)
Change to:
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:
data["CSRFField"] = s.csrfField(r)
data["CSRFToken"] = s.csrfToken(r)
Alternatively, create an executeTemplate wrapper method that auto-injects these:
// 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 templatehandleStacks→ stacks template (if separate from dashboard)handleDeploy→ deploy templatehandleBackups→ backups templatehandleMonitoring→ monitoring templatehandleSettings→ settings templatehandleAppInfo→ app_info templatehandleLogs→ logs templatehandleMigrate→ migrate templatehandleMigrateDrive→ migrate_drive templatehandleStorageInit→ init_disk templatehandleStorageAttach→ attach_disk templatehandleRestorePage→ restore templaterenderLogin→ 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:
<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:
<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:
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:
fetch('/api/stacks/' + name + '/start', {method: 'POST'})
After:
fetch('/api/stacks/' + name + '/start', {method: 'POST', headers: csrfHeaders()})
For fetch calls that already have headers (e.g., Content-Type: application/json), merge:
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:
type Server struct {
// ... existing fields ...
sessions map[string]*hubSession
sessionsMu sync.RWMutex
}
type hubSession struct {
expiresAt time.Time
csrfToken string
}
Initialize in New():
sessions: make(map[string]*hubSession),
B) Update handleLogin to create random session tokens:
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:
// 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):
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:
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:
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:
// 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:
<meta name="csrf-token" content="{{.CSRFToken}}">
Update all fetch() calls to include X-CSRF-Token header. Add helper:
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:
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 + CSRFTokenhandleConfigList— pass CSRFTokenhandleConfigNewForm— pass CSRFField + CSRFTokenhandleConfigEditForm— 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:
- Cross-site forms cannot inject Basic Auth headers
- Browser-cached Basic Auth is only auto-sent to the same origin
- 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
-
Controller first (v0.23.0) — larger surface area, more endpoints to protect
auth.go— Add csrfToken to session structcsrf.go— New file with CSRF middleware + helpersserver.go— Wire middleware, add executeTemplate or inject CSRFField/CSRFTokenhandlers.go— Pass CSRF data to all page rendersstorage_handlers.go— Pass CSRF data to storage page rendershandler_restore.go— Pass CSRF data to restore page render- Templates — Add
{{.CSRFField}}to forms + meta tags + update JS - Test: attempt POST without token → 403, with token → success
-
Hub second (v0.3.8) — fewer endpoints, session model fix is critical
server.go— Random session tokens, SameSite, Secure, cleanup- CSRF validation + helpers in server.go
configs.go— Pass CSRF data to template renders- Templates — Add hidden fields + meta tags + update JS
main.go— Start session cleanup goroutine- 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)
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)
# 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).