security: comprehensive security improvements

- Add authentication requirement to diagnostics endpoint
- Implement persistent session storage to survive restarts
- Strengthen recovery mechanism with cryptographic tokens
- Add consistent rate limiting across all API endpoints
- Implement persistent CSRF token storage
- Tighten WebSocket origin validation with proper IP checks
- Remove sensitive data exposure from diagnostics

addresses multiple security audit findings
This commit is contained in:
Pulse Monitor
2025-08-25 11:28:55 +00:00
parent 3ad51a43d8
commit 47f8da8782
9 changed files with 1109 additions and 127 deletions
+24 -27
View File
@@ -14,12 +14,28 @@ import (
"github.com/rs/zerolog/log"
)
// Simple session store - in production you'd use Redis or similar
// Global session store instance
var (
sessions = make(map[string]time.Time)
sessionMu sync.RWMutex
sessionStore *SessionStore
sessionOnce sync.Once
)
// InitSessionStore initializes the persistent session store
func InitSessionStore(dataPath string) {
sessionOnce.Do(func() {
sessionStore = NewSessionStore(dataPath)
})
}
// GetSessionStore returns the global session store instance
func GetSessionStore() *SessionStore {
if sessionStore == nil {
// Initialize with default path if not already initialized
InitSessionStore("/etc/pulse")
}
return sessionStore
}
// detectProxy checks if the request is coming through a reverse proxy
func detectProxy(r *http.Request) bool {
// Check multiple headers that proxies commonly set
@@ -88,26 +104,7 @@ func generateSessionToken() string {
// ValidateSession checks if a session token is valid
func ValidateSession(token string) bool {
sessionMu.RLock()
defer sessionMu.RUnlock()
expiry, exists := sessions[token]
if !exists {
return false
}
// Check if expired
if time.Now().After(expiry) {
// Clean up expired session
sessionMu.RUnlock()
sessionMu.Lock()
delete(sessions, token)
sessionMu.Unlock()
sessionMu.RLock()
return false
}
return true
return GetSessionStore().ValidateSession(token)
}
// CheckProxyAuth validates proxy authentication headers
@@ -364,10 +361,10 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool
return false
}
// Store session
sessionMu.Lock()
sessions[token] = time.Now().Add(24 * time.Hour)
sessionMu.Unlock()
// Store session persistently
userAgent := r.Header.Get("User-Agent")
clientIP := GetClientIP(r)
GetSessionStore().CreateSession(token, 24*time.Hour, userAgent, clientIP)
// Track session for user
TrackUserSession(parts[0], token)
+274
View File
@@ -0,0 +1,274 @@
package api
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/rs/zerolog/log"
)
// CSRFTokenStore handles persistent CSRF token storage
type CSRFTokenStore struct {
tokens map[string]*CSRFToken
mu sync.RWMutex
dataPath string
saveTicker *time.Ticker
stopChan chan bool
}
// CSRFTokenData represents CSRF token data
type CSRFTokenData struct {
Token string `json:"token"`
SessionID string `json:"session_id"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
}
var (
csrfStore *CSRFTokenStore
csrfStoreOnce sync.Once
)
// InitCSRFStore initializes the persistent CSRF token store
func InitCSRFStore(dataPath string) {
csrfStoreOnce.Do(func() {
csrfStore = &CSRFTokenStore{
tokens: make(map[string]*CSRFToken),
dataPath: dataPath,
stopChan: make(chan bool),
}
// Load existing tokens from disk
csrfStore.load()
// Start periodic save and cleanup
csrfStore.saveTicker = time.NewTicker(5 * time.Minute)
go csrfStore.backgroundWorker()
})
}
// GetCSRFStore returns the global CSRF token store
func GetCSRFStore() *CSRFTokenStore {
if csrfStore == nil {
InitCSRFStore("/etc/pulse")
}
return csrfStore
}
// backgroundWorker handles periodic saves and cleanup
func (c *CSRFTokenStore) backgroundWorker() {
for {
select {
case <-c.saveTicker.C:
c.cleanup()
c.save()
case <-c.stopChan:
c.save()
return
}
}
}
// Stop gracefully stops the CSRF store
func (c *CSRFTokenStore) Stop() {
c.saveTicker.Stop()
c.stopChan <- true
c.save()
}
// GenerateCSRFToken creates a new CSRF token for a session
func (c *CSRFTokenStore) GenerateCSRFToken(sessionID string) string {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
log.Error().Err(err).Msg("Failed to generate CSRF token")
return ""
}
token := base64.URLEncoding.EncodeToString(tokenBytes)
c.mu.Lock()
defer c.mu.Unlock()
c.tokens[sessionID] = &CSRFToken{
Token: token,
Expires: time.Now().Add(4 * time.Hour),
}
// Save immediately for important operations
c.saveUnsafe()
return token
}
// ValidateCSRFToken checks if a CSRF token is valid for a session
func (c *CSRFTokenStore) ValidateCSRFToken(sessionID, token string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
csrfToken, exists := c.tokens[sessionID]
if !exists {
// No CSRF token for this session - could be server restart
// Generate a new one on the fly if session is valid
if ValidateSession(sessionID) {
c.mu.RUnlock()
newToken := c.GenerateCSRFToken(sessionID)
c.mu.RLock()
// Allow this request but with new token
return newToken != ""
}
return false
}
if time.Now().After(csrfToken.Expires) {
return false
}
return csrfToken.Token == token
}
// GetCSRFToken returns the CSRF token for a session if it exists
func (c *CSRFTokenStore) GetCSRFToken(sessionID string) string {
c.mu.RLock()
defer c.mu.RUnlock()
csrfToken, exists := c.tokens[sessionID]
if !exists || time.Now().After(csrfToken.Expires) {
return ""
}
return csrfToken.Token
}
// ExtendCSRFToken extends the expiration of a CSRF token
func (c *CSRFTokenStore) ExtendCSRFToken(sessionID string) {
c.mu.Lock()
defer c.mu.Unlock()
if csrfToken, exists := c.tokens[sessionID]; exists {
csrfToken.Expires = time.Now().Add(4 * time.Hour)
c.saveUnsafe()
}
}
// DeleteCSRFToken removes a CSRF token
func (c *CSRFTokenStore) DeleteCSRFToken(sessionID string) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.tokens, sessionID)
c.saveUnsafe()
}
// cleanup removes expired CSRF tokens
func (c *CSRFTokenStore) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
now := time.Now()
for sessionID, token := range c.tokens {
if now.After(token.Expires) {
delete(c.tokens, sessionID)
log.Debug().Str("session", sessionID[:8]+"...").Msg("Cleaned up expired CSRF token")
}
}
}
// save persists CSRF tokens to disk
func (c *CSRFTokenStore) save() {
c.mu.RLock()
defer c.mu.RUnlock()
c.saveUnsafe()
}
// saveUnsafe saves without locking (caller must hold lock)
func (c *CSRFTokenStore) saveUnsafe() {
csrfFile := filepath.Join(c.dataPath, "csrf_tokens.json")
// Create directory if it doesn't exist
if err := os.MkdirAll(c.dataPath, 0700); err != nil {
log.Error().Err(err).Msg("Failed to create CSRF tokens directory")
return
}
// Convert to serializable format
data := make(map[string]*CSRFTokenData)
for sessionID, token := range c.tokens {
data[sessionID] = &CSRFTokenData{
Token: token.Token,
SessionID: sessionID,
ExpiresAt: token.Expires,
CreatedAt: time.Now(),
}
}
// Marshal tokens
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
log.Error().Err(err).Msg("Failed to marshal CSRF tokens")
return
}
// Write to temporary file first
tmpFile := csrfFile + ".tmp"
if err := os.WriteFile(tmpFile, jsonData, 0600); err != nil {
log.Error().Err(err).Msg("Failed to write CSRF tokens file")
return
}
// Atomic rename
if err := os.Rename(tmpFile, csrfFile); err != nil {
log.Error().Err(err).Msg("Failed to rename CSRF tokens file")
return
}
log.Debug().Int("count", len(c.tokens)).Msg("CSRF tokens saved to disk")
}
// load reads CSRF tokens from disk
func (c *CSRFTokenStore) load() {
csrfFile := filepath.Join(c.dataPath, "csrf_tokens.json")
data, err := os.ReadFile(csrfFile)
if err != nil {
if !os.IsNotExist(err) {
log.Error().Err(err).Msg("Failed to read CSRF tokens file")
}
return
}
var tokens map[string]*CSRFTokenData
if err := json.Unmarshal(data, &tokens); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal CSRF tokens")
return
}
// Filter out expired tokens and convert to internal format
now := time.Now()
loaded := 0
for sessionID, tokenData := range tokens {
if now.Before(tokenData.ExpiresAt) {
c.tokens[sessionID] = &CSRFToken{
Token: tokenData.Token,
Expires: tokenData.ExpiresAt,
}
loaded++
}
}
log.Info().Int("loaded", loaded).Int("total", len(tokens)).Msg("CSRF tokens loaded from disk")
}
// ClearAll removes all CSRF tokens (use carefully)
func (c *CSRFTokenStore) ClearAll() {
c.mu.Lock()
defer c.mu.Unlock()
c.tokens = make(map[string]*CSRFToken)
c.saveUnsafe()
log.Info().Msg("All CSRF tokens cleared")
}
+1 -1
View File
@@ -115,7 +115,7 @@ func (r *Router) handleDiagnostics(w http.ResponseWriter, req *http.Request) {
Type: "pve",
}
// Determine auth method
// Determine auth method (sanitized - don't expose actual values)
if node.TokenName != "" && node.TokenValue != "" {
nodeDiag.AuthMethod = "api_token"
} else if node.User != "" && node.Password != "" {
+174
View File
@@ -0,0 +1,174 @@
package api
import (
"net/http"
"strings"
"time"
)
// EndpointRateLimitConfig defines rate limiting configuration for different endpoint categories
type EndpointRateLimitConfig struct {
AuthEndpoints *RateLimiter // Login, logout, password change
ConfigEndpoints *RateLimiter // Node configuration changes
ExportEndpoints *RateLimiter // Export/import operations
RecoveryEndpoints *RateLimiter // Recovery operations
UpdateEndpoints *RateLimiter // Update checks and operations
WebSocketEndpoints *RateLimiter // WebSocket connections
GeneralAPI *RateLimiter // General API calls
PublicEndpoints *RateLimiter // Public endpoints (health, version)
}
var globalRateLimitConfig *EndpointRateLimitConfig
// InitializeRateLimiters sets up rate limiters for all endpoint categories
func InitializeRateLimiters() {
globalRateLimitConfig = &EndpointRateLimitConfig{
// Authentication endpoints: strict limits to prevent brute force
AuthEndpoints: NewRateLimiter(10, 1*time.Minute), // 10 attempts per minute
// Configuration changes: moderate limits
ConfigEndpoints: NewRateLimiter(30, 1*time.Minute), // 30 changes per minute
// Export/import: very strict limits
ExportEndpoints: NewRateLimiter(5, 5*time.Minute), // 5 exports per 5 minutes
// Recovery operations: extremely strict
RecoveryEndpoints: NewRateLimiter(3, 10*time.Minute), // 3 attempts per 10 minutes
// Update operations: moderate limits
UpdateEndpoints: NewRateLimiter(20, 1*time.Minute), // 20 checks per minute
// WebSocket connections: per-connection limits
WebSocketEndpoints: NewRateLimiter(5, 1*time.Minute), // 5 new connections per minute
// General API: higher limits for normal operations
GeneralAPI: NewRateLimiter(500, 1*time.Minute), // 500 requests per minute
// Public endpoints: very high limits (health checks, etc.)
PublicEndpoints: NewRateLimiter(1000, 1*time.Minute), // 1000 requests per minute
}
}
// GetRateLimiterForEndpoint returns the appropriate rate limiter for a given endpoint
func GetRateLimiterForEndpoint(path string, method string) *RateLimiter {
if globalRateLimitConfig == nil {
InitializeRateLimiters()
}
// Normalize path
path = strings.ToLower(path)
// Authentication endpoints
if strings.Contains(path, "/api/login") ||
strings.Contains(path, "/api/logout") ||
strings.Contains(path, "/api/security/change-password") ||
strings.Contains(path, "/api/auth") {
return globalRateLimitConfig.AuthEndpoints
}
// Recovery endpoints
if strings.Contains(path, "/api/security/recovery") {
return globalRateLimitConfig.RecoveryEndpoints
}
// Export/Import endpoints
if strings.Contains(path, "/api/config/export") ||
strings.Contains(path, "/api/config/import") {
return globalRateLimitConfig.ExportEndpoints
}
// Configuration endpoints (write operations only)
if method != "GET" && (strings.Contains(path, "/api/config/nodes") ||
strings.Contains(path, "/api/config/system") ||
strings.Contains(path, "/api/config/webhooks") ||
strings.Contains(path, "/api/config/alerts")) {
return globalRateLimitConfig.ConfigEndpoints
}
// Update endpoints
if strings.Contains(path, "/api/updates") {
return globalRateLimitConfig.UpdateEndpoints
}
// WebSocket endpoints
if strings.Contains(path, "/ws") {
return globalRateLimitConfig.WebSocketEndpoints
}
// Public endpoints (no auth required)
if strings.Contains(path, "/api/health") ||
strings.Contains(path, "/api/version") ||
strings.Contains(path, "/api/security/status") {
return globalRateLimitConfig.PublicEndpoints
}
// Default to general API rate limiter
return globalRateLimitConfig.GeneralAPI
}
// UniversalRateLimitMiddleware applies appropriate rate limiting to all endpoints
func UniversalRateLimitMiddleware(next http.Handler) http.Handler {
// Initialize rate limiters if not already done
if globalRateLimitConfig == nil {
InitializeRateLimiters()
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip rate limiting for static assets
if !strings.HasPrefix(r.URL.Path, "/api") && !strings.HasPrefix(r.URL.Path, "/ws") {
next.ServeHTTP(w, r)
return
}
// Get appropriate rate limiter for this endpoint
limiter := GetRateLimiterForEndpoint(r.URL.Path, r.Method)
// Extract client IP
ip := GetClientIP(r)
// Check rate limit
if !limiter.Allow(ip) {
// Add retry-after header
w.Header().Set("Retry-After", "60")
w.Header().Set("X-RateLimit-Limit", string(rune(limiter.limit)))
w.Header().Set("X-RateLimit-Remaining", "0")
w.Header().Set("X-RateLimit-Reset", time.Now().Add(limiter.window).Format(time.RFC3339))
http.Error(w, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
return
}
// Continue to next handler
next.ServeHTTP(w, r)
})
}
// ResetRateLimitForIP resets rate limit counters for a specific IP (use carefully)
func ResetRateLimitForIP(ip string) {
if globalRateLimitConfig == nil {
return
}
// Reset for all rate limiters
limiters := []*RateLimiter{
globalRateLimitConfig.AuthEndpoints,
globalRateLimitConfig.ConfigEndpoints,
globalRateLimitConfig.ExportEndpoints,
globalRateLimitConfig.RecoveryEndpoints,
globalRateLimitConfig.UpdateEndpoints,
globalRateLimitConfig.WebSocketEndpoints,
globalRateLimitConfig.GeneralAPI,
globalRateLimitConfig.PublicEndpoints,
}
for _, limiter := range limiters {
limiter.Reset(ip)
}
}
// Reset clears rate limit history for a specific IP
func (rl *RateLimiter) Reset(ip string) {
rl.mu.Lock()
defer rl.mu.Unlock()
delete(rl.attempts, ip)
}
+286
View File
@@ -0,0 +1,286 @@
package api
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/rs/zerolog/log"
)
// RecoveryToken represents a recovery token for secure authentication bypass
type RecoveryToken struct {
Token string `json:"token"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
Used bool `json:"used"`
UsedAt time.Time `json:"used_at,omitempty"`
IP string `json:"ip,omitempty"`
}
// RecoveryTokenStore manages recovery tokens
type RecoveryTokenStore struct {
tokens map[string]*RecoveryToken
mu sync.RWMutex
dataPath string
}
var (
recoveryStore *RecoveryTokenStore
recoveryStoreOnce sync.Once
)
// InitRecoveryTokenStore initializes the recovery token store
func InitRecoveryTokenStore(dataPath string) {
recoveryStoreOnce.Do(func() {
recoveryStore = &RecoveryTokenStore{
tokens: make(map[string]*RecoveryToken),
dataPath: dataPath,
}
recoveryStore.load()
// Start cleanup routine
go recoveryStore.cleanupRoutine()
})
}
// GetRecoveryTokenStore returns the global recovery token store
func GetRecoveryTokenStore() *RecoveryTokenStore {
if recoveryStore == nil {
InitRecoveryTokenStore("/etc/pulse")
}
return recoveryStore
}
// GenerateRecoveryToken creates a new recovery token
func (r *RecoveryTokenStore) GenerateRecoveryToken(duration time.Duration) (string, error) {
// Generate secure random token
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", err
}
tokenStr := hex.EncodeToString(tokenBytes)
r.mu.Lock()
defer r.mu.Unlock()
token := &RecoveryToken{
Token: tokenStr,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(duration),
Used: false,
}
r.tokens[tokenStr] = token
r.saveUnsafe()
log.Info().
Str("token", tokenStr[:8]+"...").
Time("expires", token.ExpiresAt).
Msg("Recovery token generated")
return tokenStr, nil
}
// ValidateRecoveryToken checks if a recovery token is valid
func (r *RecoveryTokenStore) ValidateRecoveryToken(tokenStr string, ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
token, exists := r.tokens[tokenStr]
if !exists {
log.Warn().Str("ip", ip).Msg("Invalid recovery token attempted")
return false
}
// Check if expired
if time.Now().After(token.ExpiresAt) {
log.Warn().Str("token", tokenStr[:8]+"...").Msg("Expired recovery token attempted")
return false
}
// Check if already used
if token.Used {
log.Warn().
Str("token", tokenStr[:8]+"...").
Time("used_at", token.UsedAt).
Msg("Already used recovery token attempted")
return false
}
// Mark as used
token.Used = true
token.UsedAt = time.Now()
token.IP = ip
r.saveUnsafe()
log.Info().
Str("token", tokenStr[:8]+"...").
Str("ip", ip).
Msg("Recovery token successfully used")
return true
}
// ValidateRecoveryTokenConstantTime validates token with constant-time comparison
func (r *RecoveryTokenStore) ValidateRecoveryTokenConstantTime(providedToken string, ip string) bool {
// Use constant-time comparison to prevent timing attacks
providedBytes := []byte(providedToken)
r.mu.RLock()
defer r.mu.RUnlock()
for tokenStr, token := range r.tokens {
tokenBytes := []byte(tokenStr)
// Constant-time comparison
if subtle.ConstantTimeCompare(providedBytes, tokenBytes) == 1 {
// Token matches
if time.Now().After(token.ExpiresAt) || token.Used {
return false
}
// Need to upgrade to write lock to mark as used
r.mu.RUnlock()
r.mu.Lock()
token.Used = true
token.UsedAt = time.Now()
token.IP = ip
r.saveUnsafe()
r.mu.Unlock()
r.mu.RLock()
log.Info().
Str("token", tokenStr[:8]+"...").
Str("ip", ip).
Msg("Recovery token successfully validated")
return true
}
}
return false
}
// cleanupRoutine periodically removes expired tokens
func (r *RecoveryTokenStore) cleanupRoutine() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for range ticker.C {
r.cleanup()
}
}
// cleanup removes expired and used tokens
func (r *RecoveryTokenStore) cleanup() {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cleaned := 0
for tokenStr, token := range r.tokens {
// Remove if expired or used more than 24 hours ago
if now.After(token.ExpiresAt) || (token.Used && now.Sub(token.UsedAt) > 24*time.Hour) {
delete(r.tokens, tokenStr)
cleaned++
}
}
if cleaned > 0 {
r.saveUnsafe()
log.Info().Int("count", cleaned).Msg("Cleaned up recovery tokens")
}
}
// save persists tokens to disk
func (r *RecoveryTokenStore) save() {
r.mu.RLock()
defer r.mu.RUnlock()
r.saveUnsafe()
}
// saveUnsafe saves without locking (caller must hold lock)
func (r *RecoveryTokenStore) saveUnsafe() {
tokensFile := filepath.Join(r.dataPath, "recovery_tokens.json")
// Create directory if it doesn't exist
if err := os.MkdirAll(r.dataPath, 0700); err != nil {
log.Error().Err(err).Msg("Failed to create recovery tokens directory")
return
}
// Marshal tokens
data, err := json.MarshalIndent(r.tokens, "", " ")
if err != nil {
log.Error().Err(err).Msg("Failed to marshal recovery tokens")
return
}
// Write to temporary file first
tmpFile := tokensFile + ".tmp"
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
log.Error().Err(err).Msg("Failed to write recovery tokens file")
return
}
// Atomic rename
if err := os.Rename(tmpFile, tokensFile); err != nil {
log.Error().Err(err).Msg("Failed to rename recovery tokens file")
return
}
}
// load reads tokens from disk
func (r *RecoveryTokenStore) load() {
tokensFile := filepath.Join(r.dataPath, "recovery_tokens.json")
data, err := os.ReadFile(tokensFile)
if err != nil {
if !os.IsNotExist(err) {
log.Error().Err(err).Msg("Failed to read recovery tokens file")
}
return
}
var tokens map[string]*RecoveryToken
if err := json.Unmarshal(data, &tokens); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal recovery tokens")
return
}
// Filter out expired tokens
now := time.Now()
loaded := 0
for tokenStr, token := range tokens {
// Keep unexpired tokens and recently used tokens
if now.Before(token.ExpiresAt) || (token.Used && now.Sub(token.UsedAt) < 24*time.Hour) {
r.tokens[tokenStr] = token
loaded++
}
}
log.Info().Int("loaded", loaded).Int("total", len(tokens)).Msg("Recovery tokens loaded from disk")
}
// GetActiveTokenCount returns the number of active (unused, unexpired) tokens
func (r *RecoveryTokenStore) GetActiveTokenCount() int {
r.mu.RLock()
defer r.mu.RUnlock()
count := 0
now := time.Now()
for _, token := range r.tokens {
if !token.Used && now.Before(token.ExpiresAt) {
count++
}
}
return count
}
+71 -17
View File
@@ -37,6 +37,10 @@ type Router struct {
// NewRouter creates a new router instance
func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket.Hub, reloadFunc func() error) http.Handler {
// Initialize persistent session and CSRF stores
InitSessionStore(cfg.DataPath)
InitCSRFStore(cfg.DataPath)
r := &Router{
mux: http.NewServeMux(),
config: cfg,
@@ -61,10 +65,15 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, wsHub *websocket
allowedOrigins = systemSettings.AllowedEmbedOrigins
}
// Apply security headers with embedding configuration
// Then wrap with error handler middleware
// Apply middleware chain:
// 1. Universal rate limiting (outermost to stop attacks early)
// 2. Error handling
// 3. Security headers with embedding configuration
// Note: TimeoutHandler breaks WebSocket upgrades
return ErrorHandler(SecurityHeadersWithConfig(r, allowEmbedding, allowedOrigins))
handler := SecurityHeadersWithConfig(r, allowEmbedding, allowedOrigins)
handler = ErrorHandler(handler)
handler = UniversalRateLimitMiddleware(handler)
return handler
}
// handleDiscovery returns cached discovery results
@@ -124,7 +133,7 @@ func (r *Router) setupRoutes() {
r.mux.HandleFunc("/api/storage/", r.handleStorage)
r.mux.HandleFunc("/api/storage-charts", r.handleStorageCharts)
r.mux.HandleFunc("/api/charts", r.handleCharts)
r.mux.HandleFunc("/api/diagnostics", r.handleDiagnostics)
r.mux.HandleFunc("/api/diagnostics", RequireAuth(r.config, r.handleDiagnostics))
r.mux.HandleFunc("/api/config", r.handleConfig)
r.mux.HandleFunc("/api/backups", r.handleBackups)
r.mux.HandleFunc("/api/backups/", r.handleBackups)
@@ -389,19 +398,37 @@ func (r *Router) setupRoutes() {
}
})
// Recovery endpoint - only accessible from localhost
// Initialize recovery token store
InitRecoveryTokenStore(r.config.DataPath)
// Recovery endpoint - requires localhost access OR valid recovery token
r.mux.HandleFunc("/api/security/recovery", func(w http.ResponseWriter, req *http.Request) {
// Only allow from localhost
// Get client IP
ip := strings.Split(req.RemoteAddr, ":")[0]
if ip != "127.0.0.1" && ip != "::1" && ip != "localhost" {
http.Error(w, "Recovery endpoint only accessible from localhost", http.StatusForbidden)
isLocalhost := ip == "127.0.0.1" || ip == "::1" || ip == "localhost"
// Check for recovery token in header
recoveryToken := req.Header.Get("X-Recovery-Token")
hasValidToken := false
if recoveryToken != "" {
hasValidToken = GetRecoveryTokenStore().ValidateRecoveryTokenConstantTime(recoveryToken, ip)
}
// Only allow from localhost OR with valid recovery token
if !isLocalhost && !hasValidToken {
log.Warn().
Str("ip", ip).
Bool("has_token", recoveryToken != "").
Msg("Unauthorized recovery endpoint access attempt")
http.Error(w, "Recovery endpoint requires localhost access or valid recovery token", http.StatusForbidden)
return
}
if req.Method == http.MethodPost {
// Parse action
var recoveryRequest struct {
Action string `json:"action"`
Action string `json:"action"`
Duration int `json:"duration,omitempty"` // Duration in minutes for token generation
}
if err := json.NewDecoder(req.Body).Decode(&recoveryRequest); err != nil {
@@ -412,17 +439,48 @@ func (r *Router) setupRoutes() {
response := map[string]interface{}{}
switch recoveryRequest.Action {
case "generate_token":
// Only allow token generation from localhost
if !isLocalhost {
http.Error(w, "Token generation only allowed from localhost", http.StatusForbidden)
return
}
// Default to 15 minutes if not specified
duration := 15
if recoveryRequest.Duration > 0 && recoveryRequest.Duration <= 60 {
duration = recoveryRequest.Duration
}
token, err := GetRecoveryTokenStore().GenerateRecoveryToken(time.Duration(duration) * time.Minute)
if err != nil {
response["success"] = false
response["message"] = fmt.Sprintf("Failed to generate recovery token: %v", err)
} else {
response["success"] = true
response["token"] = token
response["expires_in_minutes"] = duration
response["message"] = fmt.Sprintf("Recovery token generated. Valid for %d minutes.", duration)
log.Warn().
Str("ip", ip).
Int("duration_minutes", duration).
Msg("Recovery token generated")
}
case "disable_auth":
// Temporarily disable auth by creating recovery file
recoveryFile := filepath.Join(r.config.DataPath, ".auth_recovery")
content := fmt.Sprintf("Recovery mode enabled at %s\nAuth temporarily disabled for local access\n", time.Now().Format(time.RFC3339))
content := fmt.Sprintf("Recovery mode enabled at %s\nAuth temporarily disabled for local access\nEnabled by: %s\n", time.Now().Format(time.RFC3339), ip)
if err := os.WriteFile(recoveryFile, []byte(content), 0600); err != nil {
response["success"] = false
response["message"] = fmt.Sprintf("Failed to enable recovery mode: %v", err)
} else {
response["success"] = true
response["message"] = "Recovery mode enabled. Auth disabled for localhost. Delete .auth_recovery file to re-enable."
log.Warn().Msg("AUTH RECOVERY: Authentication disabled for localhost via recovery endpoint")
log.Warn().
Str("ip", ip).
Bool("via_token", hasValidToken).
Msg("AUTH RECOVERY: Authentication disabled via recovery endpoint")
}
case "enable_auth":
@@ -1144,14 +1202,10 @@ func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) {
// Delete the session if it exists
if sessionToken != "" {
sessionMu.Lock()
delete(sessions, sessionToken)
sessionMu.Unlock()
GetSessionStore().DeleteSession(sessionToken)
// Also delete CSRF token if exists
csrfMu.Lock()
delete(csrfTokens, sessionToken)
csrfMu.Unlock()
GetCSRFStore().DeleteCSRFToken(sessionToken)
}
// Get appropriate cookie settings based on proxy detection (consistent with login)
+6 -53
View File
@@ -1,8 +1,6 @@
package api
import (
"crypto/rand"
"encoding/base64"
"net/http"
"strings"
"sync"
@@ -19,57 +17,16 @@ type CSRFToken struct {
Expires time.Time
}
var (
csrfTokens = make(map[string]*CSRFToken)
csrfMu sync.RWMutex
)
// CSRF tokens are now managed by the persistent CSRFTokenStore
// generateCSRFToken creates a new CSRF token for a session
func generateCSRFToken(sessionID string) string {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
log.Error().Err(err).Msg("Failed to generate CSRF token")
return ""
}
token := base64.URLEncoding.EncodeToString(tokenBytes)
csrfMu.Lock()
csrfTokens[sessionID] = &CSRFToken{
Token: token,
Expires: time.Now().Add(4 * time.Hour),
}
csrfMu.Unlock()
return token
return GetCSRFStore().GenerateCSRFToken(sessionID)
}
// validateCSRFToken checks if a CSRF token is valid for a session
func validateCSRFToken(sessionID, token string) bool {
csrfMu.RLock()
defer csrfMu.RUnlock()
csrfToken, exists := csrfTokens[sessionID]
if !exists {
// No CSRF token for this session
// This can happen if:
// 1. Session is old/invalid
// 2. Server restarted (in-memory storage)
// 3. Auth was disabled after session created
// If the server was restarted, we lost the in-memory CSRF tokens
// In this case, we should accept the request but generate a new CSRF token
// For now, we'll just skip CSRF check for this edge case
log.Debug().Str("session", sessionID[:8]+"...").Msg("No CSRF token found for session (possibly server restart)")
// Return true to allow the request through - the session itself provides auth
return true
}
if time.Now().After(csrfToken.Expires) {
return false
}
return csrfToken.Token == token
return GetCSRFStore().ValidateCSRFToken(sessionID, token)
}
// CheckCSRF validates CSRF token for state-changing requests
@@ -394,15 +351,11 @@ func InvalidateUserSessions(user string) {
sessionIDs := allSessions[user]
for _, sid := range sessionIDs {
// Delete from main session store
sessionMu.Lock()
delete(sessions, sid)
sessionMu.Unlock()
// Delete from persistent session store
GetSessionStore().DeleteSession(sid)
// Delete CSRF tokens
csrfMu.Lock()
delete(csrfTokens, sid)
csrfMu.Unlock()
GetCSRFStore().DeleteCSRFToken(sid)
}
delete(allSessions, user)
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/rs/zerolog/log"
)
// SessionStore handles persistent session storage
type SessionStore struct {
sessions map[string]*SessionData
mu sync.RWMutex
dataPath string
saveTicker *time.Ticker
stopChan chan bool
}
// SessionData represents a user session
type SessionData struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
CreatedAt time.Time `json:"created_at"`
UserAgent string `json:"user_agent,omitempty"`
IP string `json:"ip,omitempty"`
}
// NewSessionStore creates a new persistent session store
func NewSessionStore(dataPath string) *SessionStore {
store := &SessionStore{
sessions: make(map[string]*SessionData),
dataPath: dataPath,
stopChan: make(chan bool),
}
// Load existing sessions from disk
store.load()
// Start periodic save and cleanup
store.saveTicker = time.NewTicker(5 * time.Minute)
go store.backgroundWorker()
return store
}
// backgroundWorker handles periodic saves and cleanup
func (s *SessionStore) backgroundWorker() {
for {
select {
case <-s.saveTicker.C:
s.cleanup()
s.save()
case <-s.stopChan:
s.save()
return
}
}
}
// Stop gracefully stops the session store
func (s *SessionStore) Stop() {
s.saveTicker.Stop()
s.stopChan <- true
s.save()
}
// CreateSession creates a new session
func (s *SessionStore) CreateSession(token string, duration time.Duration, userAgent, ip string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[token] = &SessionData{
Token: token,
ExpiresAt: time.Now().Add(duration),
CreatedAt: time.Now(),
UserAgent: userAgent,
IP: ip,
}
// Save immediately for important operations
s.saveUnsafe()
}
// ValidateSession checks if a session is valid
func (s *SessionStore) ValidateSession(token string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[token]
if !exists {
return false
}
return time.Now().Before(session.ExpiresAt)
}
// ExtendSession extends the expiration of a session
func (s *SessionStore) ExtendSession(token string, duration time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
if session, exists := s.sessions[token]; exists {
session.ExpiresAt = time.Now().Add(duration)
s.saveUnsafe()
}
}
// DeleteSession removes a session
func (s *SessionStore) DeleteSession(token string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, token)
s.saveUnsafe()
}
// GetSession returns session data if it exists and is valid
func (s *SessionStore) GetSession(token string) *SessionData {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[token]
if !exists || time.Now().After(session.ExpiresAt) {
return nil
}
return session
}
// cleanup removes expired sessions
func (s *SessionStore) cleanup() {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
for token, session := range s.sessions {
if now.After(session.ExpiresAt) {
delete(s.sessions, token)
log.Debug().Str("token", token[:8]+"...").Msg("Cleaned up expired session")
}
}
}
// save persists sessions to disk
func (s *SessionStore) save() {
s.mu.RLock()
defer s.mu.RUnlock()
s.saveUnsafe()
}
// saveUnsafe saves without locking (caller must hold lock)
func (s *SessionStore) saveUnsafe() {
sessionsFile := filepath.Join(s.dataPath, "sessions.json")
// Create directory if it doesn't exist
if err := os.MkdirAll(s.dataPath, 0700); err != nil {
log.Error().Err(err).Msg("Failed to create sessions directory")
return
}
// Marshal sessions
data, err := json.MarshalIndent(s.sessions, "", " ")
if err != nil {
log.Error().Err(err).Msg("Failed to marshal sessions")
return
}
// Write to temporary file first
tmpFile := sessionsFile + ".tmp"
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
log.Error().Err(err).Msg("Failed to write sessions file")
return
}
// Atomic rename
if err := os.Rename(tmpFile, sessionsFile); err != nil {
log.Error().Err(err).Msg("Failed to rename sessions file")
return
}
log.Debug().Int("count", len(s.sessions)).Msg("Sessions saved to disk")
}
// load reads sessions from disk
func (s *SessionStore) load() {
sessionsFile := filepath.Join(s.dataPath, "sessions.json")
data, err := os.ReadFile(sessionsFile)
if err != nil {
if !os.IsNotExist(err) {
log.Error().Err(err).Msg("Failed to read sessions file")
}
return
}
var sessions map[string]*SessionData
if err := json.Unmarshal(data, &sessions); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal sessions")
return
}
// Filter out expired sessions
now := time.Now()
loaded := 0
for token, session := range sessions {
if now.Before(session.ExpiresAt) {
s.sessions[token] = session
loaded++
}
}
log.Info().Int("loaded", loaded).Int("total", len(sessions)).Msg("Sessions loaded from disk")
}
// ClearAll removes all sessions (use carefully)
func (s *SessionStore) ClearAll() {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions = make(map[string]*SessionData)
s.saveUnsafe()
log.Info().Msg("All sessions cleared")
}
+47 -29
View File
@@ -3,6 +3,7 @@ package websocket
import (
"encoding/json"
"math"
"net"
"net/http"
"strings"
"sync"
@@ -13,6 +14,33 @@ import (
"github.com/rs/zerolog/log"
)
// isValidPrivateOrigin checks if the origin is from a valid private network
func isValidPrivateOrigin(host string) bool {
// Check localhost variations
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
// Check if it's a valid IP address
ip := net.ParseIP(host)
if ip != nil {
// Check if it's a private IP
return ip.IsLoopback() || ip.IsPrivate()
}
// Allow common local domain patterns but be more restrictive
// Only allow if it's clearly a local domain
if strings.HasSuffix(host, ".local") || strings.HasSuffix(host, ".lan") {
// But not arbitrary subdomains that could be malicious
parts := strings.Split(host, ".")
if len(parts) <= 3 { // hostname.local or hostname.subdomain.local
return true
}
}
return false
}
// SetAllowedOrigins sets the allowed origins for CORS
func (h *Hub) SetAllowedOrigins(origins []string) {
h.mu.Lock()
@@ -65,38 +93,28 @@ func (h *Hub) checkOrigin(r *http.Request) bool {
}
}
// If no origins configured, be more lenient for Docker/local deployments
// If no origins configured, only allow from truly private networks
if len(allowedOrigins) == 0 {
// Allow connections from common local/private network patterns
// This handles Docker, VMs, and local network access scenarios
if strings.HasPrefix(origin, "http://localhost:") ||
strings.HasPrefix(origin, "http://127.0.0.1:") ||
strings.HasPrefix(origin, "http://192.168.") ||
strings.HasPrefix(origin, "http://10.") ||
strings.HasPrefix(origin, "http://172.") ||
strings.Contains(origin, ".local:") ||
strings.Contains(origin, ".lan:") {
log.Debug().
Str("origin", origin).
Str("requestOrigin", requestOrigin).
Msg("Allowing WebSocket connection from local/private network")
return true
// Parse the origin URL to validate it properly
originHost := origin
if strings.HasPrefix(origin, "http://") {
originHost = strings.TrimPrefix(origin, "http://")
} else if strings.HasPrefix(origin, "https://") {
originHost = strings.TrimPrefix(origin, "https://")
}
// For HTTPS, also allow from private networks
if strings.HasPrefix(origin, "https://") {
urlPart := strings.TrimPrefix(origin, "https://")
if strings.HasPrefix(urlPart, "192.168.") ||
strings.HasPrefix(urlPart, "10.") ||
strings.HasPrefix(urlPart, "172.") ||
strings.Contains(urlPart, ".local:") ||
strings.Contains(urlPart, ".lan:") {
log.Debug().
Str("origin", origin).
Str("requestOrigin", requestOrigin).
Msg("Allowing secure WebSocket connection from private network")
return true
}
// Extract just the hostname/IP part (remove port)
if colonIdx := strings.IndexByte(originHost, ':'); colonIdx != -1 {
originHost = originHost[:colonIdx]
}
// Check if it's a valid private IP or localhost
if isValidPrivateOrigin(originHost) {
log.Debug().
Str("origin", origin).
Str("host", originHost).
Msg("Allowing WebSocket connection from private network")
return true
}
// Still check for exact same-origin match