diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e8fd7c5cd..ade4c06a5 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,6 +1,31 @@ # Pulse Security -## Security Warning System (v4.3.2+) +## Smart Security Context (v4.3.2+) + +### Public Access Detection +Pulse automatically detects when it's being accessed from public networks: +- **Private Networks**: Local/RFC1918 addresses (192.168.x.x, 10.x.x.x, etc.) +- **Public Networks**: Any non-private IP address +- **Stronger Warnings**: Red alerts when accessed from public IPs without authentication + +### Trusted Networks Configuration +Define networks that don't require authentication: +```bash +# Environment variable (comma-separated CIDR blocks) +PULSE_TRUSTED_NETWORKS=192.168.1.0/24,10.0.0.0/24 + +# Or in systemd +sudo systemctl edit pulse-backend +[Service] +Environment="PULSE_TRUSTED_NETWORKS=192.168.1.0/24,10.0.0.0/24" +``` + +When configured: +- Access from trusted networks: No auth required +- Access from outside: Authentication enforced +- Useful for: Mixed home/remote access scenarios + +## Security Warning System Pulse now includes a non-intrusive security warning system that helps you understand your security posture: diff --git a/frontend-modern/src/components/SecurityWarning.tsx b/frontend-modern/src/components/SecurityWarning.tsx index e7ccc36c7..adeb13da8 100644 --- a/frontend-modern/src/components/SecurityWarning.tsx +++ b/frontend-modern/src/components/SecurityWarning.tsx @@ -10,6 +10,9 @@ interface SecurityStatus { exportProtected: boolean; score: number; maxScore: number; + publicAccess?: boolean; + isPrivateNetwork?: boolean; + clientIP?: string; } export const SecurityWarning: Component = () => { @@ -52,7 +55,10 @@ export const SecurityWarning: Component = () => { credentialsEncrypted: true, // Always true in current implementation exportProtected: data.exportProtected || false, score, - maxScore + maxScore, + publicAccess: data.publicAccess || false, + isPrivateNetwork: data.isPrivateNetwork, + clientIP: data.clientIP }); } } catch (error) { @@ -88,14 +94,31 @@ export const SecurityWarning: Component = () => { return '🚨'; }; - // Don't show if dismissed or if security is good - if (dismissed() || !status() || status()!.score >= 4) { + // Show more aggressively if public access detected + const shouldShow = () => { + if (dismissed()) return false; + if (!status()) return false; + + // Always show if public access without auth + if (status()!.publicAccess && !status()!.hasAuthentication) { + return true; + } + + // Show if score is low + return status()!.score < 4; + }; + + if (!shouldShow()) { return null; } return ( -
+
@@ -116,7 +139,13 @@ export const SecurityWarning: Component = () => {

- Your Pulse instance is accessible without authentication. Proxmox credentials could be exposed. + {status()!.publicAccess ? ( + + ⚠️ PUBLIC NETWORK ACCESS DETECTED - Your Proxmox credentials are exposed to the internet! + + ) : ( + 'Your Pulse instance is accessible without authentication. Proxmox credentials could be exposed.' + )}

diff --git a/internal/api/router.go b/internal/api/router.go index c9e68db41..9d6a8bef9 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -204,6 +204,21 @@ func (r *Router) setupRoutes() { // Credentials are always encrypted in current implementation credentialsEncrypted := true + // Check network context + clientIP := utils.GetClientIP( + req.RemoteAddr, + req.Header.Get("X-Forwarded-For"), + req.Header.Get("X-Real-IP"), + ) + isPrivateNetwork := utils.IsPrivateIP(clientIP) + + // Get trusted networks from environment + trustedNetworks := []string{} + if nets := os.Getenv("PULSE_TRUSTED_NETWORKS"); nets != "" { + trustedNetworks = strings.Split(nets, ",") + } + isTrustedNetwork := utils.IsTrustedNetwork(clientIP, trustedNetworks) + status := map[string]interface{}{ "apiTokenConfigured": r.config.APIToken != "", "requiresAuth": r.config.APIToken != "", @@ -214,6 +229,10 @@ func (r *Router) setupRoutes() { "hasAuditLogging": hasAuditLogging, "credentialsEncrypted": credentialsEncrypted, "hasHTTPS": req.TLS != nil, + "clientIP": clientIP, + "isPrivateNetwork": isPrivateNetwork, + "isTrustedNetwork": isTrustedNetwork, + "publicAccess": !isPrivateNetwork, } json.NewEncoder(w).Encode(status) } else { diff --git a/internal/config/config.go b/internal/config/config.go index 234c797b3..9f4e96301 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -176,6 +176,8 @@ func Load() (*Config, error) { Int("pve", len(cfg.PVEInstances)). Int("pbs", len(cfg.PBSInstances)). Msg("Loaded nodes configuration") + } else if err != nil { + log.Warn().Err(err).Msg("Failed to load nodes configuration") } // Load system configuration diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index 5505c7f19..6857d1278 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -39,13 +39,26 @@ func getOrCreateKey() ([]byte, error) { keyPath := filepath.Join(dataDir, ".encryption.key") oldKeyPath := "/etc/pulse/.encryption.key" + log.Debug(). + Str("dataDir", dataDir). + Str("keyPath", keyPath). + Msg("Looking for encryption key") + // Try to read existing key from new location if data, err := os.ReadFile(keyPath); err == nil { key := make([]byte, 32) n, err := base64.StdEncoding.Decode(key, data) if err == nil && n == 32 { + log.Debug().Msg("Found and loaded existing encryption key") return key, nil + } else { + log.Warn(). + Err(err). + Int("decodedBytes", n). + Msg("Failed to decode encryption key") } + } else { + log.Debug().Err(err).Str("path", keyPath).Msg("Could not read encryption key file") } // Check for key in old location and migrate if found (only if paths differ) diff --git a/internal/utils/network.go b/internal/utils/network.go new file mode 100644 index 000000000..5ecb46d2e --- /dev/null +++ b/internal/utils/network.go @@ -0,0 +1,108 @@ +package utils + +import ( + "net" + "strings" +) + +// IsPrivateIP checks if an IP address is in private/local ranges (RFC1918) +func IsPrivateIP(ip string) bool { + // Extract IP without port + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + + // Remove brackets from IPv6 + ip = strings.Trim(ip, "[]") + + // Parse the IP + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false + } + + // Check if it's loopback + if parsedIP.IsLoopback() { + return true + } + + // Check if it's link-local + if parsedIP.IsLinkLocalUnicast() || parsedIP.IsLinkLocalMulticast() { + return true + } + + // Define private IP ranges (RFC1918) + privateRanges := []string{ + "10.0.0.0/8", // Class A private + "172.16.0.0/12", // Class B private + "192.168.0.0/16", // Class C private + "127.0.0.0/8", // Loopback + "::1/128", // IPv6 loopback + "fc00::/7", // IPv6 unique local + "fe80::/10", // IPv6 link-local + } + + for _, cidr := range privateRanges { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if network.Contains(parsedIP) { + return true + } + } + + return false +} + +// GetClientIP extracts the real client IP from request headers +func GetClientIP(remoteAddr string, xForwardedFor string, xRealIP string) string { + // Check X-Forwarded-For first (for proxies) + if xForwardedFor != "" { + // Take the first IP if there are multiple + if idx := strings.Index(xForwardedFor, ","); idx != -1 { + return strings.TrimSpace(xForwardedFor[:idx]) + } + return strings.TrimSpace(xForwardedFor) + } + + // Check X-Real-IP + if xRealIP != "" { + return strings.TrimSpace(xRealIP) + } + + // Fall back to RemoteAddr + return remoteAddr +} + +// IsTrustedNetwork checks if an IP is within trusted network ranges +func IsTrustedNetwork(ip string, trustedNetworks []string) bool { + // If no trusted networks defined, consider all private IPs as trusted + if len(trustedNetworks) == 0 { + return IsPrivateIP(ip) + } + + // Extract IP without port + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + ip = strings.Trim(ip, "[]") + + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + return false + } + + // Check against trusted networks + for _, cidr := range trustedNetworks { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if network.Contains(parsedIP) { + return true + } + } + + return false +} \ No newline at end of file