Files
deploy-felhom-compose/TASK.md
T
admin 02650e3202 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>
2026-02-21 16:38:56 +01:00

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:

  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:

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 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:

<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 + 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)

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).