mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 11:46:28 +00:00
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:
+24
-27
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user