mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-11 14:00:29 +00:00
feat: add smart security context detection (Phase 3)
- Detect public vs private network access - Show stronger warnings for public access without auth - Red banner when accessed from internet without authentication - Support for trusted networks configuration via PULSE_TRUSTED_NETWORKS - Automatic RFC1918 private IP detection - Enhanced security status API with network context - Added debug logging for encryption key loading
This commit is contained in:
+26
-1
@@ -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:
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Portal>
|
||||
<div class="fixed top-0 left-0 right-0 z-50 bg-yellow-50 dark:bg-yellow-900/20 border-b border-yellow-200 dark:border-yellow-800 shadow-sm">
|
||||
<div class={`fixed top-0 left-0 right-0 z-50 border-b shadow-sm ${
|
||||
status()!.publicAccess && !status()!.hasAuthentication
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
|
||||
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'
|
||||
}`}>
|
||||
<div class="max-w-7xl mx-auto px-4 py-3">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-start space-x-3">
|
||||
@@ -116,7 +139,13 @@ export const SecurityWarning: Component = () => {
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300 mt-1">
|
||||
Your Pulse instance is accessible without authentication. Proxmox credentials could be exposed.
|
||||
{status()!.publicAccess ? (
|
||||
<span class="font-semibold text-red-700 dark:text-red-300">
|
||||
⚠️ PUBLIC NETWORK ACCESS DETECTED - Your Proxmox credentials are exposed to the internet!
|
||||
</span>
|
||||
) : (
|
||||
'Your Pulse instance is accessible without authentication. Proxmox credentials could be exposed.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Show when={showDetails()}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user