mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-04-30 20:40:09 +00:00
feat(security): Add node allowlist validation to prevent SSRF attacks
Implements comprehensive node validation system to prevent SSRF attacks
via the temperature proxy. Addresses critical vulnerability where proxy
would SSH to any hostname/IP passing format validation.
Features:
- Configurable allowed_nodes list (hostnames, IPs, CIDR ranges)
- Automatic Proxmox cluster membership validation
- 5-minute cluster membership cache to reduce pvecm overhead
- strict_node_validation option for strict vs permissive modes
- New metric: pulse_proxy_node_validation_failures_total{node,reason}
- Logs blocked attempts at WARN level with 'potential SSRF attempt'
Configuration:
- allowed_nodes: [] (empty = auto-discover from cluster)
- strict_node_validation: true (require cluster membership)
Default behavior: Empty allowlist + Proxmox host = validate cluster
members (secure by default, backwards compatible).
Related to security audit 2025-11-07.
Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
parent
59a97f2e3e
commit
7062b07411
6 changed files with 745 additions and 80 deletions
|
|
@ -6,6 +6,7 @@ import (
|
|||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
|
@ -19,9 +20,16 @@ type RateLimitConfig struct {
|
|||
|
||||
// Config holds proxy configuration
|
||||
type Config struct {
|
||||
AllowedSourceSubnets []string `yaml:"allowed_source_subnets"`
|
||||
MetricsAddress string `yaml:"metrics_address"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
AllowedSourceSubnets []string `yaml:"allowed_source_subnets"`
|
||||
MetricsAddress string `yaml:"metrics_address"`
|
||||
LogLevel string `yaml:"log_level"`
|
||||
AllowedNodes []string `yaml:"allowed_nodes"`
|
||||
StrictNodeValidation bool `yaml:"strict_node_validation"`
|
||||
ReadTimeout time.Duration `yaml:"read_timeout"`
|
||||
WriteTimeout time.Duration `yaml:"write_timeout"`
|
||||
MaxSSHOutputBytes int64 `yaml:"max_ssh_output_bytes"`
|
||||
RequireProxmoxHostkeys bool `yaml:"require_proxmox_hostkeys"`
|
||||
AllowedPeers []PeerConfig `yaml:"allowed_peers"`
|
||||
|
||||
AllowIDMappedRoot bool `yaml:"allow_idmapped_root"`
|
||||
AllowedPeerUIDs []uint32 `yaml:"allowed_peer_uids"`
|
||||
|
|
@ -31,12 +39,21 @@ type Config struct {
|
|||
RateLimit *RateLimitConfig `yaml:"rate_limit,omitempty"`
|
||||
}
|
||||
|
||||
// PeerConfig represents a peer entry with capabilities.
|
||||
type PeerConfig struct {
|
||||
UID uint32 `yaml:"uid"`
|
||||
Capabilities []string `yaml:"capabilities"`
|
||||
}
|
||||
|
||||
// loadConfig loads configuration from file and environment variables
|
||||
func loadConfig(configPath string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
AllowIDMappedRoot: true,
|
||||
AllowedIDMapUsers: []string{"root"},
|
||||
LogLevel: "info", // Default log level
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
MaxSSHOutputBytes: 1 * 1024 * 1024, // 1 MiB
|
||||
}
|
||||
|
||||
// Try to load config file if it exists
|
||||
|
|
@ -58,6 +75,26 @@ func loadConfig(configPath string) (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Read timeout override
|
||||
if envReadTimeout := os.Getenv("PULSE_SENSOR_PROXY_READ_TIMEOUT"); envReadTimeout != "" {
|
||||
if parsed, err := time.ParseDuration(strings.TrimSpace(envReadTimeout)); err != nil {
|
||||
log.Warn().Str("value", envReadTimeout).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_READ_TIMEOUT value, ignoring")
|
||||
} else {
|
||||
cfg.ReadTimeout = parsed
|
||||
log.Info().Dur("read_timeout", cfg.ReadTimeout).Msg("Configured read timeout from environment")
|
||||
}
|
||||
}
|
||||
|
||||
// Write timeout override
|
||||
if envWriteTimeout := os.Getenv("PULSE_SENSOR_PROXY_WRITE_TIMEOUT"); envWriteTimeout != "" {
|
||||
if parsed, err := time.ParseDuration(strings.TrimSpace(envWriteTimeout)); err != nil {
|
||||
log.Warn().Str("value", envWriteTimeout).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_WRITE_TIMEOUT value, ignoring")
|
||||
} else {
|
||||
cfg.WriteTimeout = parsed
|
||||
log.Info().Dur("write_timeout", cfg.WriteTimeout).Msg("Configured write timeout from environment")
|
||||
}
|
||||
}
|
||||
|
||||
// Append from environment variable if set
|
||||
if envSubnets := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_SUBNETS"); envSubnets != "" {
|
||||
envList := strings.Split(envSubnets, ",")
|
||||
|
|
@ -67,6 +104,20 @@ func loadConfig(configPath string) (*Config, error) {
|
|||
Msg("Appended subnets from environment variable")
|
||||
}
|
||||
|
||||
// Ensure timeouts have sane defaults
|
||||
if cfg.ReadTimeout <= 0 {
|
||||
log.Warn().Dur("configured_value", cfg.ReadTimeout).Msg("Read timeout must be positive; using default 5s")
|
||||
cfg.ReadTimeout = 5 * time.Second
|
||||
}
|
||||
if cfg.WriteTimeout <= 0 {
|
||||
log.Warn().Dur("configured_value", cfg.WriteTimeout).Msg("Write timeout must be positive; using default 10s")
|
||||
cfg.WriteTimeout = 10 * time.Second
|
||||
}
|
||||
if cfg.MaxSSHOutputBytes <= 0 {
|
||||
log.Warn().Int64("configured_value", cfg.MaxSSHOutputBytes).Msg("max_ssh_output_bytes must be positive; using default 1MiB")
|
||||
cfg.MaxSSHOutputBytes = 1 * 1024 * 1024
|
||||
}
|
||||
|
||||
// Allow ID-mapped root override
|
||||
if envAllowIDMap := os.Getenv("PULSE_SENSOR_PROXY_ALLOW_IDMAPPED_ROOT"); envAllowIDMap != "" {
|
||||
parsed, err := parseBool(envAllowIDMap)
|
||||
|
|
@ -126,6 +177,53 @@ func loadConfig(configPath string) (*Config, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Allowed node overrides
|
||||
if envNodes := os.Getenv("PULSE_SENSOR_PROXY_ALLOWED_NODES"); envNodes != "" {
|
||||
envList := splitAndTrim(envNodes)
|
||||
if len(envList) > 0 {
|
||||
cfg.AllowedNodes = append(cfg.AllowedNodes, envList...)
|
||||
log.Info().
|
||||
Int("env_allowed_nodes", len(envList)).
|
||||
Msg("Appended allowed nodes from environment")
|
||||
}
|
||||
}
|
||||
|
||||
// Strict node validation override
|
||||
if envStrict := os.Getenv("PULSE_SENSOR_PROXY_STRICT_NODE_VALIDATION"); envStrict != "" {
|
||||
parsed, err := parseBool(envStrict)
|
||||
if err != nil {
|
||||
log.Warn().
|
||||
Str("value", envStrict).
|
||||
Err(err).
|
||||
Msg("Invalid PULSE_SENSOR_PROXY_STRICT_NODE_VALIDATION value, ignoring")
|
||||
} else {
|
||||
cfg.StrictNodeValidation = parsed
|
||||
log.Info().
|
||||
Bool("strict_node_validation", parsed).
|
||||
Msg("Configured strict node validation from environment")
|
||||
}
|
||||
}
|
||||
|
||||
// SSH output limit override
|
||||
if envMaxSSH := os.Getenv("PULSE_SENSOR_PROXY_MAX_SSH_OUTPUT_BYTES"); envMaxSSH != "" {
|
||||
if parsed, err := strconv.ParseInt(strings.TrimSpace(envMaxSSH), 10, 64); err != nil {
|
||||
log.Warn().Str("value", envMaxSSH).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_MAX_SSH_OUTPUT_BYTES value, ignoring")
|
||||
} else {
|
||||
cfg.MaxSSHOutputBytes = parsed
|
||||
log.Info().Int64("max_ssh_output_bytes", cfg.MaxSSHOutputBytes).Msg("Configured max SSH output bytes from environment")
|
||||
}
|
||||
}
|
||||
|
||||
// Require Proxmox host keys override
|
||||
if envReq := os.Getenv("PULSE_SENSOR_PROXY_REQUIRE_PROXMOX_HOSTKEYS"); envReq != "" {
|
||||
if parsed, err := parseBool(envReq); err != nil {
|
||||
log.Warn().Str("value", envReq).Err(err).Msg("Invalid PULSE_SENSOR_PROXY_REQUIRE_PROXMOX_HOSTKEYS value, ignoring")
|
||||
} else {
|
||||
cfg.RequireProxmoxHostkeys = parsed
|
||||
log.Info().Bool("require_proxmox_hostkeys", parsed).Msg("Configured Proxmox host key requirement from environment")
|
||||
}
|
||||
}
|
||||
|
||||
// Metrics address from environment variable
|
||||
if envMetrics := os.Getenv("PULSE_SENSOR_PROXY_METRICS_ADDR"); envMetrics != "" {
|
||||
cfg.MetricsAddress = envMetrics
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue