diff --git a/README.md b/README.md index 6e3685f7b..bcc71c4f2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ - Account lockout after failed login attempts - Secure session management with HttpOnly cookies - bcrypt password hashing (cost 12) - passwords NEVER stored in plain text - - SHA3-256 API token hashing - tokens NEVER stored in plain text + - API tokens stored securely with restricted file permissions - Security headers (CSP, X-Frame-Options, etc.) - Comprehensive audit logging - Live monitoring of VMs, containers, nodes, storage @@ -111,7 +111,7 @@ services: # Security (all optional - runs open by default) # - PULSE_AUTH_USER=admin # Username for web UI login # - PULSE_AUTH_PASS='$$2a$$12$$...' # Bcrypt hash - ESCAPE $ as $$ in docker-compose! - # - API_TOKEN= # SHA3-256 hashed API token (64 hex chars) + # - API_TOKEN= # API token (48 hex chars) # - ALLOW_UNPROTECTED_EXPORT=false # Allow export without auth (default: false) # ⚠️ IMPORTANT: Docker Compose requires escaping $ characters! @@ -121,7 +121,7 @@ services: # Or use a .env file where no escaping is needed # Polling & timeouts - # - POLLING_INTERVAL=3 # Seconds between node checks (default: 3) + # - POLLING_INTERVAL=10 # Fixed at 10 seconds (matches Proxmox update cycle) # - CONNECTION_TIMEOUT=10 # Connection timeout in seconds (default: 10) # Updates @@ -146,7 +146,8 @@ For isolated PBS servers, see [PBS Agent documentation](docs/PBS-AGENT.md) ## Security -- **Authentication is optional** - Run open for homelab or secured for production +- **Authentication required** - Protects your Proxmox infrastructure credentials +- **Quick setup wizard** - Secure your installation in under a minute - **Multiple auth methods**: Password authentication, API tokens, or both - **Enterprise-grade protection**: - Credentials encrypted at rest (AES-256-GCM) @@ -154,7 +155,7 @@ For isolated PBS servers, see [PBS Agent documentation](docs/PBS-AGENT.md) - Rate limiting and account lockout protection - Secure session management with HttpOnly cookies - bcrypt password hashing (cost 12) - passwords NEVER stored in plain text - - SHA3-256 API token hashing - tokens NEVER stored in plain text + - API tokens stored securely with restricted file permissions - Security headers (CSP, X-Frame-Options, etc.) - Comprehensive audit logging - **Security by design**: diff --git a/UPGRADE_NOTICE_v4.3.9.md b/UPGRADE_NOTICE_v4.3.9.md new file mode 100644 index 000000000..5606f8e4c --- /dev/null +++ b/UPGRADE_NOTICE_v4.3.9.md @@ -0,0 +1,95 @@ +# ⚠️ CRITICAL UPGRADE NOTICE - Pulse v4.3.9 + +## Breaking Change: Authentication is Now Mandatory + +### What Changed +Starting with v4.3.9, Pulse **requires** authentication to be configured. This is a security requirement, not a feature - Pulse stores Proxmox API tokens that often have write permissions, and leaving these exposed on your network is a serious security risk. + +### What This Means for You + +#### If upgrading from v4.3.8 or earlier: +1. **Your configuration is safe** - All nodes, alerts, and settings are preserved +2. You'll see a one-time security setup wizard when accessing Pulse +3. The setup takes about 30 seconds +4. After setup, everything works exactly as before, just secured + +#### For Docker users: +```bash +# Pull latest +docker pull rcourtman/pulse:latest + +# Stop and recreate (your data in pulse_data volume is safe) +docker stop pulse +docker rm pulse +docker run -d \ + --name pulse \ + -p 7655:7655 \ + -v pulse_data:/data \ + --restart unless-stopped \ + rcourtman/pulse:latest +``` + +#### For systemd users: +```bash +# Re-run the install script +curl -fsSL https://raw.githubusercontent.com/rcourtman/Pulse/main/install.sh | sudo bash + +# Or manually update +cd /opt/pulse +sudo ./install.sh +``` + +#### For ProxmoxVE Helper Script users: +```bash +# In your Pulse container console +update +``` + +### What Happens During Upgrade + +1. **First access after upgrade**: You'll see the security setup wizard +2. **Create credentials**: Choose a username and password (or use generated password) +3. **API token generated**: Automatically creates a secure API token +4. **Immediate access**: No restart needed - you're logged in immediately +5. **All settings preserved**: Your nodes, alerts, webhooks - everything is still there + +### Why We Made This Change + +We discovered that many users were running Pulse exposed on their networks without authentication, unknowingly exposing Proxmox API tokens that could have write access to their infrastructure. Rather than make this an optional security warning that could be dismissed, we made the difficult decision to enforce security by default. + +We understand this is disruptive, but we believe protecting your infrastructure is worth the one-time inconvenience. + +### Need to Export Your Config First? + +If you want to backup your configuration before upgrading (not necessary, but for peace of mind): + +#### For v4.3.8 users without auth: +```bash +# Export your config (works without auth in v4.3.8) +curl http://your-pulse:7655/api/export -o pulse-backup.json +``` + +#### For users with auth: +```bash +# Export with API token +curl -H "X-API-Token: your-token" http://your-pulse:7655/api/export -o pulse-backup.json +``` + +### Questions or Issues? + +- GitHub Issues: https://github.com/rcourtman/Pulse/issues +- The upgrade preserves all data - if something goes wrong, your config is still in `/etc/pulse/` or `/data/` + +### For API/Automation Users + +If you're using Pulse's API for automation: +1. Complete the security setup via web UI first +2. Generate an API token in Settings → Security +3. Update your scripts to include the token: + ```bash + curl -H "X-API-Token: your-token" http://pulse:7655/api/state + ``` + +--- + +We apologize for the disruption, but this change was necessary to protect your infrastructure. The setup takes less than a minute, and your Proxmox environments will be much more secure. \ No newline at end of file diff --git a/cmd/pulse/main.go b/cmd/pulse/main.go index f168fd2c5..3c4617456 100644 --- a/cmd/pulse/main.go +++ b/cmd/pulse/main.go @@ -69,8 +69,31 @@ func runServer() { // Initialize WebSocket hub first wsHub := websocket.NewHub(nil) // Set allowed origins from configuration - if cfg.AllowedOrigins != "" && cfg.AllowedOrigins != "*" { - wsHub.SetAllowedOrigins(strings.Split(cfg.AllowedOrigins, ",")) + if cfg.AllowedOrigins != "" { + if cfg.AllowedOrigins == "*" { + // Explicit wildcard - allow all origins (less secure) + wsHub.SetAllowedOrigins([]string{"*"}) + } else { + // Use configured origins + wsHub.SetAllowedOrigins(strings.Split(cfg.AllowedOrigins, ",")) + } + } else { + // Default: allow same-origin only (more secure) + // This will be dynamically set based on the actual request host + allowedOrigins := []string{} + // Add localhost variants for development + allowedOrigins = append(allowedOrigins, + "http://localhost:"+fmt.Sprintf("%d", cfg.FrontendPort), + "http://127.0.0.1:"+fmt.Sprintf("%d", cfg.FrontendPort), + ) + // If HTTPS is likely being used, add those too + if cfg.FrontendPort == 443 || cfg.FrontendPort == 8443 { + allowedOrigins = append(allowedOrigins, + "https://localhost:"+fmt.Sprintf("%d", cfg.FrontendPort), + "https://127.0.0.1:"+fmt.Sprintf("%d", cfg.FrontendPort), + ) + } + wsHub.SetAllowedOrigins(allowedOrigins) } go wsHub.Run() diff --git a/docs/API.md b/docs/API.md index 0939ccce0..17a43d1b7 100644 --- a/docs/API.md +++ b/docs/API.md @@ -26,17 +26,19 @@ docker run -e PULSE_AUTH_USER=admin -e PULSE_AUTH_PASS=your-password rcourtman/p Once set, users must login via the web UI. The password can be changed from Settings → Security. ### API Token Authentication -For programmatic API access and automation. Tokens can be generated and managed via the web UI (Settings → Security → API Token). +For programmatic API access and automation. Tokens can be generated via the web UI (Settings → Security → Generate API Token). + +**API-Only Mode**: If only API_TOKEN is configured (no password auth), the UI remains accessible in read-only mode while API modifications require the token. ```bash # Systemd sudo systemctl edit pulse-backend # Add: [Service] -Environment="API_TOKEN=your-secure-token-here" +Environment="API_TOKEN=your-48-char-hex-token" # Docker -docker run -e API_TOKEN=your-secure-token rcourtman/pulse:latest +docker run -e API_TOKEN=your-48-char-hex-token rcourtman/pulse:latest ``` ### Using Authentication diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 3a928d803..1e5f91c58 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -27,7 +27,7 @@ All configuration files are stored in `/etc/pulse/` (or `/data/` in Docker conta # User authentication PULSE_AUTH_USER='admin' # Admin username PULSE_AUTH_PASS='$2a$12$...' # Bcrypt hashed password (keep quotes!) -API_TOKEN=abc123... # API authentication token +API_TOKEN=abc123... # API token (plain text, not hashed) # Security settings ENABLE_AUDIT_LOG=true # Enable security audit logging @@ -35,9 +35,11 @@ ENABLE_AUDIT_LOG=true # Enable security audit logging **Important Notes:** - Password hash MUST be in single quotes to prevent shell expansion +- API tokens are stored in plain text (48 hex characters) - This file should have restricted permissions (600) - Never commit this file to version control - ProxmoxVE installations may pre-configure API_TOKEN +- Changes to this file are applied immediately without restart (v4.3.9+) --- @@ -50,7 +52,7 @@ ENABLE_AUDIT_LOG=true # Enable security audit logging **Contents:** ```json { - "pollingInterval": 5, // Seconds between node polls (2-60) + "pollingInterval": 10, // Fixed at 10 seconds to match Proxmox update cycle "connectionTimeout": 10, // Seconds before node connection timeout "autoUpdateEnabled": false, // Enable automatic updates "updateChannel": "stable", // Update channel: stable, rc, beta @@ -66,6 +68,7 @@ ENABLE_AUDIT_LOG=true # Enable security audit logging - Can be safely backed up without exposing secrets - Missing file results in defaults being used - Changes take effect immediately (no restart required) +- API tokens are no longer managed in system.json (moved to .env in v4.3.9+) --- diff --git a/docs/DOCKER.md b/docs/DOCKER.md index 313c1f898..88b43682d 100644 --- a/docs/DOCKER.md +++ b/docs/DOCKER.md @@ -48,7 +48,7 @@ services: # IMPORTANT: Use $$ instead of $ in docker-compose.yml! PULSE_AUTH_USER: 'admin' PULSE_AUTH_PASS: '$$2a$$12$$YourHashHere...' # <-- Note the $$ - API_TOKEN: 'your-sha3-256-token' + API_TOKEN: 'your-48-char-hex-token' restart: unless-stopped volumes: @@ -61,7 +61,7 @@ Create `.env` file (no escaping needed): ```env PULSE_AUTH_USER=admin PULSE_AUTH_PASS=$2a$12$YourHashHere... -API_TOKEN=your-sha3-256-token +API_TOKEN=your-48-char-hex-token ``` Docker-compose.yml: @@ -88,7 +88,7 @@ volumes: |----------|-------------|---------| | `PULSE_AUTH_USER` | Username for web UI | `admin` | | `PULSE_AUTH_PASS` | Bcrypt password hash (60 chars) | `$2a$12$...` | -| `API_TOKEN` | SHA3-256 hashed API token | 64 hex characters | +| `API_TOKEN` | API token (plain text) | 48 hex characters | | `ALLOW_UNPROTECTED_EXPORT` | Allow export without auth | `false` | ### Network Configuration @@ -134,9 +134,9 @@ docker run --rm \ ### Method 1: Quick Security Setup (Recommended) 1. Start container WITHOUT auth environment variables 2. Access http://your-server:7655 -3. Follow the Quick Security Setup wizard +3. Follow the mandatory Quick Security Setup wizard 4. Credentials are saved to `/data/.env` -5. Container restart not needed +5. Security is active immediately (no restart needed) ### Method 2: Manual Configuration 1. Generate password hash: @@ -147,8 +147,8 @@ docker run --rm \ 2. Generate API token: ```bash - # Generate random token - openssl rand -hex 32 + # Generate random token (24 bytes = 48 hex chars) + openssl rand -hex 24 ``` 3. Add to docker-compose.yml (remember to escape $ as $$) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 5ee9ad691..bfb352dea 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,15 @@ # Pulse Security -## Smart Security Context (v4.3.2+) +## Authentication Setup + +Pulse includes a Quick Security Setup wizard that helps you configure authentication when first accessing an unsecured instance. This protects your Proxmox API credentials from unauthorized access. + +### First-Run Security Setup +- Interactive wizard for username, password, and API token generation +- Settings are applied immediately without restart +- Protects access to your Proxmox infrastructure credentials + +## Smart Security Context ### Public Access Detection Pulse automatically detects when it's being accessed from public networks: @@ -61,7 +70,7 @@ By default, configuration export/import is blocked for security. You have two op sudo systemctl edit pulse-backend # Add: [Service] -Environment="API_TOKEN=your-secure-token-here" +Environment="API_TOKEN=your-48-char-hex-token" # Then restart: sudo systemctl restart pulse-backend @@ -98,6 +107,11 @@ docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest - Passwords NEVER stored in plain text - Automatic hashing on security setup - **CRITICAL**: Bcrypt hashes MUST be exactly 60 characters +- **API Token Security**: + - 48-character hex tokens (24 bytes of entropy) + - Stored in plain text with file permissions (600) + - Live reloading when .env file changes (v4.3.9+) + - API-only mode supported (no password auth required) - **Docker Users**: Always wrap hash in single quotes to prevent shell expansion - **API Token Security**: - SHA3-256 hashing for all tokens diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 95301a032..6fdf729d1 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -104,7 +104,7 @@ systemctl status pulse 2>/dev/null || systemctl status pulse-backend ### Performance Issues #### High CPU usage -- Reduce polling interval in Settings → System +- Polling interval is fixed at 10 seconds (matches Proxmox update cycle) - Check number of monitored nodes - Disable unused features (snapshots, backups monitoring) diff --git a/frontend-modern/cookies.txt b/frontend-modern/cookies.txt new file mode 100644 index 000000000..c31d9899c --- /dev/null +++ b/frontend-modern/cookies.txt @@ -0,0 +1,4 @@ +# Netscape HTTP Cookie File +# https://curl.se/docs/http-cookies.html +# This file was generated by libcurl! Edit at your own risk. + diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index ef9393a94..dc989deb8 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -99,6 +99,18 @@ function App() { // Check auth on mount onMount(async () => { console.log('[App] Starting auth check...'); + + // Check if we just logged out - if so, always show login page + const justLoggedOut = localStorage.getItem('just_logged_out'); + if (justLoggedOut) { + localStorage.removeItem('just_logged_out'); + console.log('[App] Just logged out, showing login page'); + setHasAuth(true); // Force showing login instead of setup + setNeedsAuth(true); + setIsLoading(false); + return; + } + // First check security status to see if auth is configured try { const securityRes = await fetch('/api/security/status'); @@ -170,9 +182,10 @@ function App() { console.error('Logout error:', error); } - // Clear all local storage + // Clear all local storage EXCEPT a flag indicating this is a logout localStorage.clear(); sessionStorage.clear(); + localStorage.setItem('just_logged_out', 'true'); // Clear WebSocket connection if (wsStore()) { diff --git a/frontend-modern/src/components/FirstRunSetup.tsx b/frontend-modern/src/components/FirstRunSetup.tsx index aee3d09fb..4d6106ecb 100644 --- a/frontend-modern/src/components/FirstRunSetup.tsx +++ b/frontend-modern/src/components/FirstRunSetup.tsx @@ -17,7 +17,6 @@ export const FirstRunSetup: Component = () => { const [copied, setCopied] = createSignal<'password' | 'token' | null>(null); // Additional setup options - const [enableNotifications, setEnableNotifications] = createSignal(true); const [darkMode, setDarkMode] = createSignal( window.matchMedia('(prefers-color-scheme: dark)').matches ); @@ -82,7 +81,6 @@ export const FirstRunSetup: Component = () => { username: username(), password: finalPassword, apiToken: token, - enableNotifications: enableNotifications(), darkMode: darkMode() }) }); @@ -107,7 +105,6 @@ export const FirstRunSetup: Component = () => { // Apply settings localStorage.setItem('dark-mode', String(darkMode())); - localStorage.setItem('notifications-enabled', String(enableNotifications())); // Show credentials setShowCredentials(true); @@ -272,31 +269,6 @@ IMPORTANT: Keep these credentials secure! Initial Configuration - {/* Enable Notifications */} -
-
- -

- Get notified about alerts and important events -

-
- -
- {/* Dark Mode */}
diff --git a/frontend-modern/src/components/Settings/Settings.tsx b/frontend-modern/src/components/Settings/Settings.tsx index 9eb0626f0..766dd11bb 100644 --- a/frontend-modern/src/components/Settings/Settings.tsx +++ b/frontend-modern/src/components/Settings/Settings.tsx @@ -186,7 +186,8 @@ const Settings: Component = () => { const loadSecurityStatus = async () => { setSecurityStatusLoading(true); try { - const response = await fetch('/api/security/status'); + const { apiFetch } = await import('@/utils/apiClient'); + const response = await apiFetch('/api/security/status'); if (response.ok) { const status = await response.json(); setSecurityStatus(status); diff --git a/internal/api/auth.go b/internal/api/auth.go index 9f32047b8..6b74f79ef 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -4,7 +4,6 @@ import ( cryptorand "crypto/rand" "encoding/base64" "encoding/hex" - "fmt" "net/http" "strings" "sync" @@ -118,14 +117,11 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool return false } - log.Info(). + log.Debug(). Str("configured_user", cfg.AuthUser). Bool("has_pass", cfg.AuthPass != ""). Bool("has_token", cfg.APIToken != ""). - Str("api_token_length", fmt.Sprintf("%d", len(cfg.APIToken))). - Str("api_token_first_chars", fmt.Sprintf("%.10s...", cfg.APIToken)). Str("url", r.URL.Path). - Str("provided_token", r.Header.Get("X-API-Token")). Msg("Checking authentication") // Check API token first (for backward compatibility) @@ -134,9 +130,8 @@ func CheckAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request) bool if token := r.Header.Get("X-API-Token"); token != "" { // Check if stored token is hashed or plain text isHashed := internalauth.IsAPITokenHashed(cfg.APIToken) - log.Info(). + log.Debug(). Bool("is_hashed", isHashed). - Bool("tokens_match", token == cfg.APIToken). Msg("Comparing API tokens") if isHashed { diff --git a/internal/api/config_handlers.go b/internal/api/config_handlers.go index 320686494..90e82d72e 100644 --- a/internal/api/config_handlers.go +++ b/internal/api/config_handlers.go @@ -1796,7 +1796,6 @@ func (h *ConfigHandlers) HandleSetupScript(w http.ResponseWriter, r *http.Reques } log.Info(). - Str("token", tempToken). Str("type", serverType). Str("host", serverHost). Bool("has_auth", h.config.AuthUser != "" || h.config.AuthPass != "" || h.config.APIToken != ""). @@ -1811,7 +1810,6 @@ func (h *ConfigHandlers) HandleSetupScript(w http.ResponseWriter, r *http.Reques h.tokenMutex.RUnlock() log.Debug(). - Str("token", "***REDACTED***"). Bool("exists", exists). Time("expiry", expiry). Int("total_tokens", len(h.setupTokens)). @@ -2392,7 +2390,6 @@ func (h *ConfigHandlers) HandleSetupScriptURL(w http.ResponseWriter, r *http.Req h.tokenMutex.Unlock() log.Info(). - Str("token", token). Time("expiry", expiry). Int("total_tokens", len(h.setupTokens)). Msg("Generated setup token") diff --git a/internal/api/router.go b/internal/api/router.go index fcb4510f1..e74845d77 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -830,10 +830,10 @@ func (r *Router) handleChangePassword(w http.ResponseWriter, req *http.Request) return } - // Validate new password - if len(changeReq.NewPassword) < 8 { + // Validate new password complexity + if err := auth.ValidatePasswordComplexity(changeReq.NewPassword); err != nil { writeErrorResponse(w, http.StatusBadRequest, "invalid_password", - "Password must be at least 8 characters", nil) + err.Error(), nil) return } diff --git a/internal/api/security_setup_fix.go b/internal/api/security_setup_fix.go index 308616fa4..5abee7878 100644 --- a/internal/api/security_setup_fix.go +++ b/internal/api/security_setup_fix.go @@ -62,6 +62,14 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { return } + // Apply rate limiting to prevent brute force attacks + clientIP := GetClientIP(req) + if !authLimiter.Allow(clientIP) { + log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for security setup") + http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests) + return + } + // Check if password auth is already configured // Allow adding password auth on top of API-only access if r.config.AuthUser != "" && r.config.AuthPass != "" { @@ -97,6 +105,12 @@ func handleQuickSecuritySetupFixed(r *Router) http.HandlerFunc { return } + // Validate password complexity + if err := auth.ValidatePasswordComplexity(setupRequest.Password); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Set default polling interval if not provided if setupRequest.PollingInterval == 0 { setupRequest.PollingInterval = 5 @@ -338,6 +352,14 @@ func (r *Router) HandleRegenerateAPIToken(w http.ResponseWriter, rq *http.Reques return } + // Apply rate limiting to prevent abuse + clientIP := GetClientIP(rq) + if !authLimiter.Allow(clientIP) { + log.Warn().Str("ip", clientIP).Msg("Rate limit exceeded for API token generation") + http.Error(w, "Too many attempts. Please try again later.", http.StatusTooManyRequests) + return + } + // Generate new token (24 bytes = 48 hex chars, not 64 to avoid hash detection issue) tokenBytes := make([]byte, 24) if _, err := rand.Read(tokenBytes); err != nil { diff --git a/internal/auth/password.go b/internal/auth/password.go index 0b198932d..46662b25b 100644 --- a/internal/auth/password.go +++ b/internal/auth/password.go @@ -1,6 +1,7 @@ package auth import ( + "fmt" "strings" "golang.org/x/crypto/bcrypt" ) @@ -9,6 +10,9 @@ const ( // BcryptCost is the cost factor for bcrypt hashing // Higher values are more secure but slower BcryptCost = 12 + + // MinPasswordLength is the minimum required password length + MinPasswordLength = 8 ) // HashPassword generates a bcrypt hash from a plain text password @@ -41,4 +45,15 @@ func MigratePassword(password string) (string, error) { } // Plain text password, hash it return HashPassword(password) +} + +// ValidatePasswordComplexity checks if a password meets complexity requirements +func ValidatePasswordComplexity(password string) error { + if len(password) < MinPasswordLength { + return fmt.Errorf("password must be at least %d characters long", MinPasswordLength) + } + + // That's it - let users choose their own passwords + // No annoying character type requirements + return nil } \ No newline at end of file diff --git a/internal/config/config.go b/internal/config/config.go index 162a3d118..b86af799e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -316,16 +316,16 @@ func Load() (*Config, error) { } if apiToken := os.Getenv("API_TOKEN"); apiToken != "" { cfg.APIToken = apiToken - log.Info().Msg("Loaded API token from env var") + log.Debug().Msg("Loaded API token from env var") } // Check if API token is enabled if apiTokenEnabled := os.Getenv("API_TOKEN_ENABLED"); apiTokenEnabled != "" { cfg.APITokenEnabled = apiTokenEnabled == "true" || apiTokenEnabled == "1" - log.Info().Bool("enabled", cfg.APITokenEnabled).Msg("API token enabled status from env var") + log.Debug().Bool("enabled", cfg.APITokenEnabled).Msg("API token enabled status from env var") } else if cfg.APIToken != "" { // If token exists but no explicit enabled flag, assume enabled for backwards compatibility cfg.APITokenEnabled = true - log.Info().Msg("API token exists without explicit enabled flag, assuming enabled for backwards compatibility") + log.Debug().Msg("API token exists without explicit enabled flag, assuming enabled for backwards compatibility") } if authUser := os.Getenv("PULSE_AUTH_USER"); authUser != "" { cfg.AuthUser = authUser @@ -344,7 +344,7 @@ func Load() (*Config, error) { log.Error().Msg("Ensure the full hash is enclosed in single quotes in your .env file or Docker environment") } } - log.Info().Bool("is_hashed", IsPasswordHashed(authPass)).Int("length", len(authPass)).Msg("Loaded auth password from env var") + log.Debug().Bool("is_hashed", IsPasswordHashed(authPass)).Msg("Loaded auth password from env var") } // REMOVED: Update channel, auto-update, connection timeout, and allowed origins env vars // These settings now ONLY come from system.json to prevent confusion diff --git a/internal/config/persistence.go b/internal/config/persistence.go index 0a27d2adc..c56eb2cb8 100644 --- a/internal/config/persistence.go +++ b/internal/config/persistence.go @@ -62,7 +62,7 @@ func NewConfigPersistence(configDir string) *ConfigPersistence { // EnsureConfigDir ensures the configuration directory exists func (c *ConfigPersistence) EnsureConfigDir() error { - return os.MkdirAll(c.configDir, 0755) + return os.MkdirAll(c.configDir, 0700) } // SaveAlertConfig saves alert configuration to file @@ -94,7 +94,7 @@ func (c *ConfigPersistence) SaveAlertConfig(config alerts.AlertConfig) error { return err } - if err := os.WriteFile(c.alertFile, data, 0644); err != nil { + if err := os.WriteFile(c.alertFile, data, 0600); err != nil { return err } @@ -247,7 +247,7 @@ func (c *ConfigPersistence) SaveWebhooks(webhooks []notifications.WebhookConfig) return err } - if err := os.WriteFile(c.webhookFile, data, 0644); err != nil { + if err := os.WriteFile(c.webhookFile, data, 0600); err != nil { return err } @@ -427,7 +427,7 @@ func (c *ConfigPersistence) SaveSystemSettings(settings SystemSettings) error { return err } - if err := os.WriteFile(c.systemFile, data, 0644); err != nil { + if err := os.WriteFile(c.systemFile, data, 0600); err != nil { return err } diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index 3a87ec17c..7be253efa 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -31,6 +31,16 @@ func (h *Hub) checkOrigin(r *http.Request) bool { allowedOrigins := h.allowedOrigins h.mu.RUnlock() + // Always allow same-origin requests + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + requestOrigin := scheme + "://" + r.Host + if origin == requestOrigin { + return true + } + // Check if wildcard is allowed for _, allowed := range allowedOrigins { if allowed == "*" { @@ -41,8 +51,14 @@ func (h *Hub) checkOrigin(r *http.Request) bool { } } + // If no origins configured, allow same-origin only + if len(allowedOrigins) == 0 { + return origin == requestOrigin + } + log.Warn(). Str("origin", origin). + Str("requestOrigin", requestOrigin). Strs("allowedOrigins", allowedOrigins). Msg("WebSocket connection rejected due to CORS") @@ -91,7 +107,7 @@ func NewHub(getState func() interface{}) *Hub { register: make(chan *Client), unregister: make(chan *Client), getState: getState, - allowedOrigins: []string{"*"}, // Default to allow all + allowedOrigins: []string{}, // Default to empty (will be set based on actual host) } } diff --git a/test-security-fixes.sh b/test-security-fixes.sh new file mode 100755 index 000000000..7c434d5f7 --- /dev/null +++ b/test-security-fixes.sh @@ -0,0 +1,143 @@ +#!/bin/bash + +echo "=== Security Fixes Test Script ===" +echo + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Test 1: Check that logs don't contain sensitive data +echo "Test 1: Checking for sensitive data in logs..." +if grep -E "(api_token_first_chars|token.*:.*[a-zA-Z0-9]{10,})" /opt/pulse/pulse.log 2>/dev/null | tail -5; then + echo -e "${RED}✗ Found potential sensitive data in logs${NC}" +else + echo -e "${GREEN}✓ No sensitive token data found in recent logs${NC}" +fi +echo + +# Test 2: Test password complexity validation +echo "Test 2: Testing password complexity validation..." +echo "Testing weak passwords that should be rejected:" + +# Create a test Go program to test password validation +cat > /tmp/test-password.go << 'EOF' +package main + +import ( + "fmt" + "github.com/rcourtman/pulse-go-rewrite/internal/auth" +) + +func main() { + tests := []struct { + password string + shouldFail bool + description string + }{ + {"weak", true, "Too short"}, + {"password123", true, "Contains weak pattern"}, + {"Admin123!", false, "Strong password"}, + {"P@ssw0rd!", true, "Contains weak pattern"}, + {"MyStr0ng!Pass", false, "Strong password"}, + {"12345678", true, "Only numbers"}, + {"abcdefgh", true, "Only lowercase"}, + {"ABCDEFGH", true, "Only uppercase"}, + {"Ab1!Cd2@", false, "Strong password"}, + } + + for _, test := range tests { + err := auth.ValidatePasswordComplexity(test.password) + if test.shouldFail && err == nil { + fmt.Printf("✗ FAIL: '%s' (%s) - Expected rejection but was accepted\n", test.password, test.description) + } else if !test.shouldFail && err != nil { + fmt.Printf("✗ FAIL: '%s' (%s) - Expected acceptance but was rejected: %v\n", test.password, test.description, err) + } else if test.shouldFail && err != nil { + fmt.Printf("✓ PASS: '%s' (%s) - Correctly rejected: %v\n", test.password, test.description, err) + } else { + fmt.Printf("✓ PASS: '%s' (%s) - Correctly accepted\n", test.password, test.description) + } + } +} +EOF + +cd /opt/pulse && go run /tmp/test-password.go +echo + +# Test 3: Check file permissions +echo "Test 3: Checking file permissions for sensitive files..." +FILES_TO_CHECK=( + "/etc/pulse/nodes.json" + "/etc/pulse/system.json" + "/etc/pulse/email.json" + "/etc/pulse/webhooks.json" + "/etc/pulse/.encryption.key" +) + +for file in "${FILES_TO_CHECK[@]}"; do + if [ -f "$file" ]; then + perms=$(stat -c %a "$file") + if [ "$perms" = "600" ] || [ "$perms" = "400" ]; then + echo -e "${GREEN}✓ $file has secure permissions ($perms)${NC}" + else + echo -e "${YELLOW}⚠ $file has permissions $perms (should be 600)${NC}" + fi + fi +done +echo + +# Test 4: Check WebSocket origin restrictions +echo "Test 4: Testing WebSocket origin restrictions..." +# Try to connect with an invalid origin +response=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Origin: http://evil.com" \ + -H "Connection: Upgrade" \ + -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Version: 13" \ + -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ + http://localhost:7655/ws) + +if [ "$response" = "403" ] || [ "$response" = "401" ]; then + echo -e "${GREEN}✓ WebSocket correctly rejects invalid origins (HTTP $response)${NC}" +else + echo -e "${YELLOW}⚠ WebSocket returned HTTP $response for invalid origin (expected 403/401)${NC}" +fi +echo + +# Test 5: Check rate limiting +echo "Test 5: Testing rate limiting on security endpoints..." +echo "Attempting 15 rapid requests to trigger rate limiting..." +for i in {1..15}; do + response=$(curl -s -o /dev/null -w "%{http_code}" -X POST http://localhost:7655/api/security/setup \ + -H "Content-Type: application/json" \ + -d '{"username":"test","password":"test","apiToken":"test"}') + if [ "$response" = "429" ]; then + echo -e "${GREEN}✓ Rate limiting triggered after $i attempts (HTTP 429)${NC}" + break + elif [ "$i" = "15" ]; then + echo -e "${YELLOW}⚠ Rate limiting not triggered after 15 attempts${NC}" + fi +done +echo + +# Test 6: Check security headers +echo "Test 6: Checking security headers..." +headers=$(curl -s -I http://localhost:7655/api/health) +security_headers=( + "X-Frame-Options: DENY" + "X-Content-Type-Options: nosniff" + "Content-Security-Policy:" +) + +for header in "${security_headers[@]}"; do + if echo "$headers" | grep -q "$header"; then + echo -e "${GREEN}✓ Header present: $header${NC}" + else + echo -e "${RED}✗ Missing header: $header${NC}" + fi +done +echo + +echo "=== Security Test Complete ===" \ No newline at end of file