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:
Pulse Monitor
2025-08-12 20:23:37 +00:00
parent da6dc52a91
commit 7323aae12b
6 changed files with 202 additions and 6 deletions
+26 -1
View File
@@ -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()}>
+19
View File
@@ -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 {
+2
View File
@@ -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
+13
View File
@@ -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)
+108
View File
@@ -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
}