fix: comprehensive security improvements and UI fixes

- Remove overly restrictive password complexity requirements (now only 8+ chars)
- Fix Change Password section not appearing in Settings > Security
- Fix logout sometimes showing setup page instead of login page
- Remove misleading desktop notifications option from first-run setup
- Improve rate limiting on authentication endpoints
- Fix sensitive data appearing in logs (passwords, tokens)
- Enhance file permissions for sensitive files (0600)
- Fix WebSocket origin validation defaults
- Add password complexity validation for setup
- Improve CSRF token handling after server restarts
- Fix security status API using wrong fetch client
- Add logout race condition prevention

Security improvements:
- No credential leakage in logs
- Proper bcrypt password hashing
- Session management enhancements
- Rate limiting on all auth endpoints
- Secure file permissions on sensitive data
This commit is contained in:
Pulse Monitor 2025-08-16 21:10:24 +00:00
parent 8129056eb8
commit e661665d24
21 changed files with 390 additions and 74 deletions

View file

@ -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-hash> # SHA3-256 hashed API token (64 hex chars)
# - API_TOKEN=<hex-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**:

95
UPGRADE_NOTICE_v4.3.9.md Normal file
View file

@ -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.

View file

@ -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()

View file

@ -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

View file

@ -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+)
---

View file

@ -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 $$)

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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()) {

View file

@ -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
</h3>
{/* Enable Notifications */}
<div class="flex items-center justify-between">
<div>
<label class="text-sm text-gray-700 dark:text-gray-300">
Enable Desktop Notifications
</label>
<p class="text-xs text-gray-500 dark:text-gray-500">
Get notified about alerts and important events
</p>
</div>
<button
type="button"
onClick={() => setEnableNotifications(!enableNotifications())}
class={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
enableNotifications() ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
class={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
enableNotifications() ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
{/* Dark Mode */}
<div class="flex items-center justify-between">
<div>

View file

@ -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);

View file

@ -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 {

View file

@ -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")

View file

@ -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
}

View file

@ -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 {

View file

@ -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
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}
}

143
test-security-fixes.sh Executable file
View file

@ -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 ==="