From 67f53a4ccdae24a6de328c97d9b60a1120db9a25 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 21 Feb 2026 16:39:14 +0100 Subject: [PATCH] =?UTF-8?q?hub=20v0.3.8=20=E2=80=94=20CSRF=20protection=20?= =?UTF-8?q?+=20secure=20session=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server.go: replace literal hub_session=authenticated with random 64-char hex session tokens stored server-side (hubSession map + sync.RWMutex); per-session CSRF tokens; CleanupSessions goroutine; SameSite=Lax+Secure cookie; CSRF validation in ServeHTTP; csrfToken/csrfField helpers - configs.go: add html/template import; pass CSRFField/CSRFToken to all template renders; renderConfigForm gains r *http.Request parameter - config_form.html: {{.CSRFField}} in form - customer_unified.html: meta csrf-token + csrfHeaders() JS; {{.CSRFField}} in all 5 POST forms; csrfHeaders() on 3 fetch calls - main.go: start CleanupSessions goroutine Co-Authored-By: Claude Sonnet 4.6 --- hub/README.md | 18 ++ hub/cmd/hub/main.go | 3 + hub/internal/web/configs.go | 19 ++- hub/internal/web/server.go | 154 ++++++++++++++++-- hub/internal/web/templates/config_form.html | 1 + .../web/templates/customer_unified.html | 13 +- 6 files changed, 187 insertions(+), 21 deletions(-) diff --git a/hub/README.md b/hub/README.md index edf44cd..5aceb61 100644 --- a/hub/README.md +++ b/hub/README.md @@ -158,6 +158,24 @@ The asset manager (`internal/assets/`) scans the assets directory on startup, bu Protected by bcrypt password + session cookie (7-day expiry). +### Authentication & Session Model (`internal/web/server.go`) + +- Login generates a **cryptographically random 64-char hex session token** stored server-side in a `map[string]*hubSession` (+ `sync.RWMutex`). The old literal `hub_session=authenticated` cookie is gone. +- Each session also stores a **per-session CSRF token** (separate 64-char hex random value). +- Cookie attributes: `SameSite=Lax`, `Secure` (when TLS), `HttpOnly`, 7-day `Max-Age`. +- `RequireAuth` middleware validates the session token with `subtle.ConstantTimeCompare` and redirects to `/login` on failure. +- `CleanupSessions(ctx)` goroutine runs hourly to purge expired sessions. + +### CSRF Protection (`internal/web/server.go`) + +Synchronizer-token CSRF protection on all browser POST/DELETE/PATCH operations: + +- CSRF validation block runs at the top of `ServeHTTP` before routing. +- Skipped when: no session cookie present (API/Basic-Auth path); or safe methods (GET/HEAD/OPTIONS). +- Token read from `_csrf` form field or `X-CSRF-Token` request header. +- On failure: JSON `{"ok":false,"error":"CSRF token missing or invalid"}` for `/api/` paths; HTTP 403 text otherwise. +- Template delivery: `csrfToken(r)` and `csrfField(r)` helpers inject `CSRFToken` and `CSRFField` into every render data struct via `configs.go`. Templates use `{{.CSRFField}}` in forms and `csrfHeaders()` JS helper for fetch calls. + ### Pages - **Dashboard (`/`)** — Fleet overview table showing all customers with live status and event count badges (error+warning in last 24h). Config-only customers (no reports yet) appear as "PENDING" with gray badge. Blocked customers are hidden. Auto-refreshes every 60 seconds. diff --git a/hub/cmd/hub/main.go b/hub/cmd/hub/main.go index 0c5878e..37a5871 100644 --- a/hub/cmd/hub/main.go +++ b/hub/cmd/hub/main.go @@ -192,6 +192,9 @@ func main() { } webServer.SetVersionChecker(versionChecker) + // Session cleanup — removes expired sessions every hour + go webServer.CleanupSessions(ctx) + // Prune on startup, then daily at configured time (default 04:30) if cfg.Retention.MaxDays > 0 { pruneAll(dataStore, cfg.Retention.MaxDays, logger) diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index a0c0aa9..fd4f27c 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "html/template" "io" "net/http" "regexp" @@ -106,10 +107,12 @@ func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { Customers []customerListEntry ActiveNav string Flash string + CSRFToken string }{ Customers: entries, ActiveNav: "configs", Flash: r.URL.Query().Get("flash"), + CSRFToken: s.csrfToken(r), } s.templates.ExecuteTemplate(w, "configs.html", data) } @@ -276,6 +279,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c Flash string ActiveNav string + CSRFField template.HTML + CSRFToken string } data := pageData{ @@ -312,6 +317,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c Flash: r.URL.Query().Get("flash"), ActiveNav: "configs", + CSRFField: s.csrfField(r), + CSRFToken: s.csrfToken(r), } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -328,11 +335,13 @@ func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { Overrides map[string]interface{} ActiveNav string Error string + CSRFField template.HTML }{ IsNew: true, Config: &store.CustomerConfig{}, Overrides: make(map[string]interface{}), ActiveNav: "configs", + CSRFField: s.csrfField(r), } s.templates.ExecuteTemplate(w, "config_form.html", data) } @@ -346,7 +355,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { customerID := strings.TrimSpace(r.FormValue("customer_id")) if customerID == "" || !validCustomerID.MatchString(customerID) { - s.renderConfigForm(w, true, &store.CustomerConfig{ + s.renderConfigForm(w, r, true, &store.CustomerConfig{ CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), @@ -357,7 +366,7 @@ func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { // Check for duplicates existing, _ := s.store.GetCustomerConfig(customerID) if existing != nil { - s.renderConfigForm(w, true, &store.CustomerConfig{ + s.renderConfigForm(w, r, true, &store.CustomerConfig{ CustomerID: customerID, CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), @@ -418,11 +427,13 @@ func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, cu Overrides map[string]interface{} ActiveNav string Error string + CSRFField template.HTML }{ IsNew: false, Config: cfg, Overrides: overrides, ActiveNav: "configs", + CSRFField: s.csrfField(r), } s.templates.ExecuteTemplate(w, "config_form.html", data) } @@ -655,7 +666,7 @@ func (s *Server) handleCreateConfigFromReport(w http.ResponseWriter, r *http.Req } // renderConfigForm is a helper to re-render the form with an error. -func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { +func (s *Server) renderConfigForm(w http.ResponseWriter, r *http.Request, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { if overrides == nil { overrides = make(map[string]interface{}) } @@ -665,12 +676,14 @@ func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store. Overrides map[string]interface{} ActiveNav string Error string + CSRFField template.HTML }{ IsNew: isNew, Config: cfg, Overrides: overrides, ActiveNav: "configs", Error: errMsg, + CSRFField: s.csrfField(r), } s.templates.ExecuteTemplate(w, "config_form.html", data) } diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index 4e8cca8..9b7c262 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -1,6 +1,10 @@ package web import ( + "context" + "crypto/rand" + "crypto/subtle" + "encoding/hex" "encoding/json" "fmt" "html/template" @@ -10,12 +14,19 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) +// hubSession holds per-session auth and CSRF data. +type hubSession struct { + expiresAt time.Time + csrfToken string +} + // Server handles the dashboard web UI. type Server struct { store *store.Store @@ -27,6 +38,9 @@ type Server struct { staleThreshold time.Duration versionChecker *VersionChecker templateFetcher *TemplateFetcher + + sessions map[string]*hubSession + sessionsMu sync.RWMutex } // New creates a new web server. @@ -61,6 +75,28 @@ func New(store *store.Store, passwordHash, apiKey, version string, staleThreshol logger: logger, templates: tmpl, staleThreshold: staleThreshold, + sessions: make(map[string]*hubSession), + } +} + +// CleanupSessions removes expired sessions. Call with: go s.CleanupSessions(ctx). +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() + } } } @@ -78,6 +114,18 @@ func (s *Server) SetTemplateFetcher(tf *TemplateFetcher) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path + // CSRF protection for all state-changing requests (web routes only). + // API routes (/api/v1/) are Bearer-token authenticated and exempt. + 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 + } + } + } + switch { case path == "/": s.handleDashboard(w, r) @@ -190,7 +238,7 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } } -// RequireAuth wraps a handler with basic authentication. +// RequireAuth wraps a handler with session or basic authentication. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth if no password configured @@ -199,49 +247,125 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { return } - // Check session cookie - if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" { + // Always allow the login page through (GET and POST) + if r.URL.Path == "/login" { next.ServeHTTP(w, r) return } - // Check basic auth + // Check session cookie (random token stored server-side) + 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 + } + } + + // Check basic auth (for programmatic/CLI access) _, password, ok := r.BasicAuth() if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { next.ServeHTTP(w, r) return } - // Show login page for browser requests - if r.URL.Path == "/login" && r.Method == http.MethodPost { - s.handleLogin(w, r) + // Redirect browsers to login page; send 401 for API-like requests + if r.Header.Get("Accept") == "application/json" || r.Header.Get("X-Requested-With") != "" { + w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`) + http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - - w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`) - http.Error(w, "Unauthorized", http.StatusUnauthorized) + http.Redirect(w, r, "/login", http.StatusFound) }) } 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 { + if s.passwordHash != "" && 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: "authenticated", + Value: sessionToken, Path: "/", HttpOnly: true, - MaxAge: 86400 * 7, // 7 days + SameSite: http.SameSiteLaxMode, + Secure: isSecure, + MaxAge: 86400 * 7, }) http.Redirect(w, r, "/", http.StatusSeeOther) return } - http.Error(w, "Invalid password", http.StatusUnauthorized) + // Render login with error + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`Felhom Hub — Bejelentkezés

Felhom Hub

Hibás jelszó

`)) return } w.WriteHeader(http.StatusOK) - w.Write([]byte(`
`)) + w.Write([]byte(`Felhom Hub — Bejelentkezés

Felhom Hub

`)) +} + +// validateCSRF checks the CSRF token for a session-based request. +// Returns true if CSRF is valid or if no session cookie is present (Basic Auth path). +func (s *Server) validateCSRF(r *http.Request) bool { + cookie, err := r.Cookie("hub_session") + if err != nil { + // No session cookie — likely Basic Auth or programmatic access; skip CSRF + return true + } + + 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 +} + +// 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 embedding in forms. +func (s *Server) csrfField(r *http.Request) template.HTML { + tok := s.csrfToken(r) + return template.HTML(``) } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { diff --git a/hub/internal/web/templates/config_form.html b/hub/internal/web/templates/config_form.html index d3899cb..8cf36cc 100644 --- a/hub/internal/web/templates/config_form.html +++ b/hub/internal/web/templates/config_form.html @@ -24,6 +24,7 @@ {{end}}
+ {{.CSRFField}}

Customer Identity

diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 32504b3..52ae115 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -6,6 +6,8 @@ {{if .CustomerName}}{{.CustomerName}}{{else}}{{.CustomerID}}{{end}} — Felhom Hub {{if .HasReports}}{{end}} + +
@@ -52,20 +54,24 @@ Edit {{if .IsBlocked}} + {{.CSRFField}} {{else}}
+ {{.CSRFField}}
{{end}}
+ {{.CSRFField}}
{{else}}
+ {{.CSRFField}}
{{end}} @@ -301,6 +307,7 @@
+ {{.CSRFField}}
@@ -560,7 +567,7 @@ btn.disabled = true; btn.textContent = 'Triggering...'; msg.style.display = 'none'; - fetch('/customers/' + customerID + '/trigger-update', {method: 'POST'}) + fetch('/customers/' + customerID + '/trigger-update', {method: 'POST', headers: csrfHeaders()}) .then(function(r) { return r.json(); }) .then(function(data) { if (data.ok) { @@ -591,7 +598,7 @@ btn.disabled = true; btn.textContent = 'Pushing...'; msg.style.display = 'none'; - fetch('/customers/' + customerID + '/push-config', {method: 'POST'}) + fetch('/customers/' + customerID + '/push-config', {method: 'POST', headers: csrfHeaders()}) .then(function(r) { return r.json(); }) .then(function(data) { if (data.ok) { @@ -622,7 +629,7 @@ btn.disabled = true; btn.textContent = 'Pulling...'; msg.style.display = 'none'; - fetch('/customers/' + customerID + '/pull-config', {method: 'POST'}) + fetch('/customers/' + customerID + '/pull-config', {method: 'POST', headers: csrfHeaders()}) .then(function(r) { return r.json(); }) .then(function(data) { if (data.ok) {