hub v0.3.8 — CSRF protection + secure session model
- 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 <noreply@anthropic.com>
This commit is contained in:
+139
-15
@@ -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(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><p style="color:red">Hibás jelszó</p><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
|
||||
w.Write([]byte(`<html><head><title>Felhom Hub — Bejelentkezés</title></head><body style="font-family:sans-serif;display:flex;justify-content:center;padding-top:4rem"><form method="post" style="display:flex;flex-direction:column;gap:.75rem;width:300px"><h2>Felhom Hub</h2><input type="password" name="password" placeholder="Jelszó" autofocus style="padding:.5rem;border:1px solid #ccc;border-radius:4px"><button type="submit" style="padding:.5rem;background:#0088cc;color:#fff;border:none;border-radius:4px;cursor:pointer">Bejelentkezés</button></form></body></html>`))
|
||||
}
|
||||
|
||||
// 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(`<input type="hidden" name="_csrf" value="` + template.HTMLEscapeString(tok) + `">`)
|
||||
}
|
||||
|
||||
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
Reference in New Issue
Block a user