feat: Add configurable allowlist for webhook private IP targets (addresses #673)
Allow homelab users to send webhooks to internal services while maintaining security defaults. Changes: - Add webhookAllowedPrivateCIDRs field to SystemSettings (persistent config) - Implement CIDR parsing and validation in NotificationManager - Convert ValidateWebhookURL to instance method to access allowlist - Add UI controls in System Settings for configuring trusted CIDR ranges - Maintain strict security by default (block all private IPs) - Keep localhost, link-local, and cloud metadata services blocked regardless of allowlist - Re-validate on both config save and webhook delivery (DNS rebinding protection) - Add comprehensive tests for CIDR parsing and IP matching Backend: - UpdateAllowedPrivateCIDRs() parses comma-separated CIDRs with validation - Support for bare IPs (auto-converts to /32 or /128) - Thread-safe allowlist updates with RWMutex - Logging when allowlist is updated or used - Validation errors prevent invalid CIDRs from being saved Frontend: - New "Webhook Security" section in System Settings - Input field with examples and helpful placeholder text - Real-time unsaved changes tracking - Loads and saves allowlist via system settings API Security: - Default behavior unchanged (all private IPs blocked) - Explicit opt-in required via configuration - Localhost (127/8) always blocked - Link-local (169.254/16) always blocked - Cloud metadata services always blocked - DNS resolution checked at both save and send time Testing: - Tests for CIDR parsing (valid/invalid inputs) - Tests for IP allowlist matching - Tests for bare IP address handling - Tests for security boundaries (localhost, link-local remain blocked) Related to #673 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6bb53eaadb
commit
1b221cca71
9 changed files with 352 additions and 11 deletions
|
|
@ -644,6 +644,9 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
const [allowEmbedding, setAllowEmbedding] = createSignal(false);
|
const [allowEmbedding, setAllowEmbedding] = createSignal(false);
|
||||||
const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal('');
|
const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal('');
|
||||||
|
|
||||||
|
// Webhook security settings
|
||||||
|
const [webhookAllowedPrivateCIDRs, setWebhookAllowedPrivateCIDRs] = createSignal('');
|
||||||
|
|
||||||
// Update settings
|
// Update settings
|
||||||
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
|
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
|
||||||
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
|
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
|
||||||
|
|
@ -1659,6 +1662,8 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
// Load embedding settings
|
// Load embedding settings
|
||||||
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
|
||||||
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
|
||||||
|
// Load webhook security settings
|
||||||
|
setWebhookAllowedPrivateCIDRs(systemSettings.webhookAllowedPrivateCIDRs || '');
|
||||||
setTemperatureMonitoringEnabled(
|
setTemperatureMonitoringEnabled(
|
||||||
typeof systemSettings.temperatureMonitoringEnabled === 'boolean'
|
typeof systemSettings.temperatureMonitoringEnabled === 'boolean'
|
||||||
? systemSettings.temperatureMonitoringEnabled
|
? systemSettings.temperatureMonitoringEnabled
|
||||||
|
|
@ -1775,6 +1780,7 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
backupPollingInterval: backupPollingInterval(),
|
backupPollingInterval: backupPollingInterval(),
|
||||||
allowEmbedding: allowEmbedding(),
|
allowEmbedding: allowEmbedding(),
|
||||||
allowedEmbedOrigins: allowedEmbedOrigins(),
|
allowedEmbedOrigins: allowedEmbedOrigins(),
|
||||||
|
webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3759,6 +3765,54 @@ const Settings: Component<SettingsProps> = (props) => {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Webhook Security Settings */}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width={2}
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Webhook Security
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Allowed Private IP Ranges for Webhooks
|
||||||
|
</label>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
By default, webhooks to private IP addresses are blocked for
|
||||||
|
security. Enter trusted CIDR ranges to allow webhooks to internal
|
||||||
|
services (leave empty to block all private IPs).
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={webhookAllowedPrivateCIDRs()}
|
||||||
|
onChange={(e) => {
|
||||||
|
setWebhookAllowedPrivateCIDRs(e.currentTarget.value);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}}
|
||||||
|
placeholder="192.168.1.0/24, 10.0.0.0/8"
|
||||||
|
class="w-full px-3 py-1.5 text-sm border rounded-lg border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Example: <code>192.168.1.0/24,10.0.0.0/8</code> allows webhooks to
|
||||||
|
these private networks. Localhost and cloud metadata services
|
||||||
|
remain blocked.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
tone="warning"
|
tone="warning"
|
||||||
padding="sm"
|
padding="sm"
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ export interface SystemConfig {
|
||||||
discoverySubnet?: string; // Subnet to scan for discovery (default: 'auto')
|
discoverySubnet?: string; // Subnet to scan for discovery (default: 'auto')
|
||||||
allowEmbedding?: boolean; // Allow iframe embedding
|
allowEmbedding?: boolean; // Allow iframe embedding
|
||||||
allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding
|
allowedEmbedOrigins?: string; // Comma-separated list of allowed origins for embedding
|
||||||
|
webhookAllowedPrivateCIDRs?: string; // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ func (h *NotificationHandlers) CreateWebhook(w http.ResponseWriter, r *http.Requ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate webhook URL
|
// Validate webhook URL
|
||||||
if err := notifications.ValidateWebhookURL(webhook.URL); err != nil {
|
if err := h.monitor.GetNotificationManager().ValidateWebhookURL(webhook.URL); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -258,7 +258,7 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate webhook URL
|
// Validate webhook URL
|
||||||
if err := notifications.ValidateWebhookURL(webhook.URL); err != nil {
|
if err := h.monitor.GetNotificationManager().ValidateWebhookURL(webhook.URL); err != nil {
|
||||||
http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("Invalid webhook URL: %v", err), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1172,6 +1172,15 @@ func (r *Router) reloadSystemSettings() {
|
||||||
if systemSettings, err := r.persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
|
if systemSettings, err := r.persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
|
||||||
r.cachedAllowEmbedding = systemSettings.AllowEmbedding
|
r.cachedAllowEmbedding = systemSettings.AllowEmbedding
|
||||||
r.cachedAllowedOrigins = systemSettings.AllowedEmbedOrigins
|
r.cachedAllowedOrigins = systemSettings.AllowedEmbedOrigins
|
||||||
|
|
||||||
|
// Update webhook allowed private CIDRs in notification manager
|
||||||
|
if r.monitor != nil {
|
||||||
|
if nm := r.monitor.GetNotificationManager(); nm != nil {
|
||||||
|
if err := nm.UpdateAllowedPrivateCIDRs(systemSettings.WebhookAllowedPrivateCIDRs); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to update webhook allowed private CIDRs during settings reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// On error, use safe defaults
|
// On error, use safe defaults
|
||||||
r.cachedAllowEmbedding = false
|
r.cachedAllowEmbedding = false
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
|
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/discovery"
|
"github.com/rcourtman/pulse-go-rewrite/internal/discovery"
|
||||||
|
"github.com/rcourtman/pulse-go-rewrite/internal/notifications"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
|
||||||
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
|
@ -33,6 +34,7 @@ type SystemSettingsHandler struct {
|
||||||
StopDiscoveryService()
|
StopDiscoveryService()
|
||||||
EnableTemperatureMonitoring()
|
EnableTemperatureMonitoring()
|
||||||
DisableTemperatureMonitoring()
|
DisableTemperatureMonitoring()
|
||||||
|
GetNotificationManager() *notifications.NotificationManager
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +45,7 @@ func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPers
|
||||||
StopDiscoveryService()
|
StopDiscoveryService()
|
||||||
EnableTemperatureMonitoring()
|
EnableTemperatureMonitoring()
|
||||||
DisableTemperatureMonitoring()
|
DisableTemperatureMonitoring()
|
||||||
|
GetNotificationManager() *notifications.NotificationManager
|
||||||
}, reloadSystemSettingsFunc func()) *SystemSettingsHandler {
|
}, reloadSystemSettingsFunc func()) *SystemSettingsHandler {
|
||||||
return &SystemSettingsHandler{
|
return &SystemSettingsHandler{
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
|
@ -60,6 +63,7 @@ func (h *SystemSettingsHandler) SetMonitor(m interface {
|
||||||
StopDiscoveryService()
|
StopDiscoveryService()
|
||||||
EnableTemperatureMonitoring()
|
EnableTemperatureMonitoring()
|
||||||
DisableTemperatureMonitoring()
|
DisableTemperatureMonitoring()
|
||||||
|
GetNotificationManager() *notifications.NotificationManager
|
||||||
}) {
|
}) {
|
||||||
h.monitor = m
|
h.monitor = m
|
||||||
}
|
}
|
||||||
|
|
@ -549,6 +553,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
||||||
if _, ok := rawRequest["allowedEmbedOrigins"]; ok {
|
if _, ok := rawRequest["allowedEmbedOrigins"]; ok {
|
||||||
settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins
|
settings.AllowedEmbedOrigins = updates.AllowedEmbedOrigins
|
||||||
}
|
}
|
||||||
|
// Allow configuring webhook private CIDR allowlist
|
||||||
|
if _, ok := rawRequest["webhookAllowedPrivateCIDRs"]; ok {
|
||||||
|
settings.WebhookAllowedPrivateCIDRs = updates.WebhookAllowedPrivateCIDRs
|
||||||
|
}
|
||||||
|
|
||||||
// Boolean fields need special handling since false is a valid value
|
// Boolean fields need special handling since false is a valid value
|
||||||
if _, ok := rawRequest["autoUpdateEnabled"]; ok {
|
if _, ok := rawRequest["autoUpdateEnabled"]; ok {
|
||||||
|
|
@ -656,6 +664,17 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update webhook allowed private CIDRs if changed
|
||||||
|
if _, ok := rawRequest["webhookAllowedPrivateCIDRs"]; ok && h.monitor != nil {
|
||||||
|
if nm := h.monitor.GetNotificationManager(); nm != nil {
|
||||||
|
if err := nm.UpdateAllowedPrivateCIDRs(settings.WebhookAllowedPrivateCIDRs); err != nil {
|
||||||
|
log.Error().Err(err).Msg("Failed to update webhook allowed private CIDRs")
|
||||||
|
http.Error(w, fmt.Sprintf("Invalid webhook allowed private CIDRs: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save to persistence
|
// Save to persistence
|
||||||
if err := h.persistence.SaveSystemSettings(settings); err != nil {
|
if err := h.persistence.SaveSystemSettings(settings); err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to save system settings")
|
log.Error().Err(err).Msg("Failed to save system settings")
|
||||||
|
|
|
||||||
|
|
@ -830,6 +830,7 @@ type SystemSettings struct {
|
||||||
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
|
||||||
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
|
DNSCacheTimeout int `json:"dnsCacheTimeout,omitempty"` // DNS cache timeout in seconds (0 = default 5 minutes)
|
||||||
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
|
SSHPort int `json:"sshPort,omitempty"` // Default SSH port for temperature monitoring (0 = use 22)
|
||||||
|
WebhookAllowedPrivateCIDRs string `json:"webhookAllowedPrivateCIDRs,omitempty"` // Comma-separated list of private CIDR ranges allowed for webhooks (e.g., "192.168.1.0/24,10.0.0.0/8")
|
||||||
// APIToken removed - now handled via .env file only
|
// APIToken removed - now handled via .env file only
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// createSecureWebhookClient creates an HTTP client with security controls
|
// createSecureWebhookClient creates an HTTP client with security controls
|
||||||
func createSecureWebhookClient(timeout time.Duration) *http.Client {
|
func (n *NotificationManager) createSecureWebhookClient(timeout time.Duration) *http.Client {
|
||||||
redirectCount := 0
|
redirectCount := 0
|
||||||
return &http.Client{
|
return &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
|
|
@ -57,7 +57,7 @@ func createSecureWebhookClient(timeout time.Duration) *http.Client {
|
||||||
newURL := req.URL.String()
|
newURL := req.URL.String()
|
||||||
|
|
||||||
// Prevent redirects to localhost or private networks (SSRF protection)
|
// Prevent redirects to localhost or private networks (SSRF protection)
|
||||||
if err := ValidateWebhookURL(newURL); err != nil {
|
if err := n.ValidateWebhookURL(newURL); err != nil {
|
||||||
log.Warn().
|
log.Warn().
|
||||||
Str("original", via[0].URL.String()).
|
Str("original", via[0].URL.String()).
|
||||||
Str("redirect", newURL).
|
Str("redirect", newURL).
|
||||||
|
|
@ -126,6 +126,8 @@ type NotificationManager struct {
|
||||||
queue *NotificationQueue // Persistent notification queue
|
queue *NotificationQueue // Persistent notification queue
|
||||||
webhookClient *http.Client // Shared HTTP client for webhooks
|
webhookClient *http.Client // Shared HTTP client for webhooks
|
||||||
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
||||||
|
allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets
|
||||||
|
allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets
|
||||||
}
|
}
|
||||||
|
|
||||||
type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error)
|
type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error)
|
||||||
|
|
@ -353,10 +355,12 @@ func NewNotificationManager(publicURL string) *NotificationManager {
|
||||||
publicURL: cleanURL,
|
publicURL: cleanURL,
|
||||||
appriseExec: defaultAppriseExec,
|
appriseExec: defaultAppriseExec,
|
||||||
queue: queue,
|
queue: queue,
|
||||||
webhookClient: createSecureWebhookClient(WebhookTimeout),
|
|
||||||
stopCleanup: make(chan struct{}),
|
stopCleanup: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create webhook client after NotificationManager is initialized
|
||||||
|
nm.webhookClient = nm.createSecureWebhookClient(WebhookTimeout)
|
||||||
|
|
||||||
// Wire up queue processor if queue is available
|
// Wire up queue processor if queue is available
|
||||||
if queue != nil {
|
if queue != nil {
|
||||||
queue.SetProcessor(nm.ProcessQueuedNotification)
|
queue.SetProcessor(nm.ProcessQueuedNotification)
|
||||||
|
|
@ -953,7 +957,7 @@ func (n *NotificationManager) sendAppriseViaHTTP(cfg AppriseConfig, title, body,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Apprise server URL to prevent SSRF
|
// Validate Apprise server URL to prevent SSRF
|
||||||
if err := ValidateWebhookURL(serverURL); err != nil {
|
if err := n.ValidateWebhookURL(serverURL); err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Str("serverURL", serverURL).
|
Str("serverURL", serverURL).
|
||||||
|
|
@ -1445,7 +1449,7 @@ func (n *NotificationManager) checkWebhookRateLimit(webhookURL string) bool {
|
||||||
// sendWebhookRequest sends the actual webhook request
|
// sendWebhookRequest sends the actual webhook request
|
||||||
func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData []byte, alertType string) error {
|
func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData []byte, alertType string) error {
|
||||||
// Re-validate webhook URL to prevent DNS rebinding attacks
|
// Re-validate webhook URL to prevent DNS rebinding attacks
|
||||||
if err := ValidateWebhookURL(webhook.URL); err != nil {
|
if err := n.ValidateWebhookURL(webhook.URL); err != nil {
|
||||||
log.Error().
|
log.Error().
|
||||||
Err(err).
|
Err(err).
|
||||||
Str("webhook", webhook.Name).
|
Str("webhook", webhook.Name).
|
||||||
|
|
@ -1974,7 +1978,7 @@ func isNumeric(s string) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateWebhookURL validates that a webhook URL is safe and properly formed
|
// ValidateWebhookURL validates that a webhook URL is safe and properly formed
|
||||||
func ValidateWebhookURL(webhookURL string) error {
|
func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error {
|
||||||
if webhookURL == "" {
|
if webhookURL == "" {
|
||||||
return fmt.Errorf("webhook URL cannot be empty")
|
return fmt.Errorf("webhook URL cannot be empty")
|
||||||
}
|
}
|
||||||
|
|
@ -2015,7 +2019,15 @@ func ValidateWebhookURL(webhookURL string) error {
|
||||||
// Check all resolved IPs for private ranges
|
// Check all resolved IPs for private ranges
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
if isPrivateIP(ip) {
|
if isPrivateIP(ip) {
|
||||||
return fmt.Errorf("webhook URL resolves to private IP %s - private networks are not allowed for security", ip.String())
|
// Check if this private IP is in the allowlist
|
||||||
|
if n.isIPInAllowlist(ip) {
|
||||||
|
log.Debug().
|
||||||
|
Str("ip", ip.String()).
|
||||||
|
Str("url", webhookURL).
|
||||||
|
Msg("Webhook URL resolves to private IP in allowlist")
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("webhook URL resolves to private IP %s - private networks are not allowed for security (configure allowlist in System Settings)", ip.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2105,6 +2117,80 @@ func isPrivateRange172(host string) bool {
|
||||||
return second >= 16 && second <= 31
|
return second >= 16 && second <= 31
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAllowedPrivateCIDRs parses and updates the list of allowed private CIDR ranges for webhooks
|
||||||
|
func (n *NotificationManager) UpdateAllowedPrivateCIDRs(cidrsString string) error {
|
||||||
|
n.allowedPrivateMu.Lock()
|
||||||
|
defer n.allowedPrivateMu.Unlock()
|
||||||
|
|
||||||
|
// Clear existing allowlist
|
||||||
|
n.allowedPrivateNets = nil
|
||||||
|
|
||||||
|
// Empty string means no allowlist (block all private IPs)
|
||||||
|
if cidrsString == "" {
|
||||||
|
log.Info().Msg("Webhook private IP allowlist cleared - all private IPs blocked")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse comma-separated CIDRs
|
||||||
|
cidrs := strings.Split(cidrsString, ",")
|
||||||
|
var parsedNets []*net.IPNet
|
||||||
|
|
||||||
|
for _, cidr := range cidrs {
|
||||||
|
cidr = strings.TrimSpace(cidr)
|
||||||
|
if cidr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Support bare IPs by adding /32 or /128
|
||||||
|
if !strings.Contains(cidr, "/") {
|
||||||
|
ip := net.ParseIP(cidr)
|
||||||
|
if ip == nil {
|
||||||
|
return fmt.Errorf("invalid IP address: %s", cidr)
|
||||||
|
}
|
||||||
|
if ip.To4() != nil {
|
||||||
|
cidr = cidr + "/32"
|
||||||
|
} else {
|
||||||
|
cidr = cidr + "/128"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid CIDR range %s: %w", cidr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedNets = append(parsedNets, ipNet)
|
||||||
|
}
|
||||||
|
|
||||||
|
n.allowedPrivateNets = parsedNets
|
||||||
|
log.Info().
|
||||||
|
Str("cidrs", cidrsString).
|
||||||
|
Int("count", len(parsedNets)).
|
||||||
|
Msg("Webhook private IP allowlist updated")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isIPInAllowlist checks if an IP is in the configured allowlist
|
||||||
|
func (n *NotificationManager) isIPInAllowlist(ip net.IP) bool {
|
||||||
|
n.allowedPrivateMu.RLock()
|
||||||
|
defer n.allowedPrivateMu.RUnlock()
|
||||||
|
|
||||||
|
// No allowlist means block all private IPs
|
||||||
|
if len(n.allowedPrivateNets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if IP is in any allowed range
|
||||||
|
for _, ipNet := range n.allowedPrivateNets {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// addWebhookDelivery adds a webhook delivery record to the history
|
// addWebhookDelivery adds a webhook delivery record to the history
|
||||||
func (n *NotificationManager) addWebhookDelivery(delivery WebhookDelivery) {
|
func (n *NotificationManager) addWebhookDelivery(delivery WebhookDelivery) {
|
||||||
n.mu.Lock()
|
n.mu.Lock()
|
||||||
|
|
|
||||||
171
internal/notifications/webhook_allowlist_test.go
Normal file
171
internal/notifications/webhook_allowlist_test.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
package notifications
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpdateAllowedPrivateCIDRs(t *testing.T) {
|
||||||
|
nm := NewNotificationManager("")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
cidrs string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty string clears allowlist",
|
||||||
|
cidrs: "",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single valid CIDR",
|
||||||
|
cidrs: "192.168.1.0/24",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple valid CIDRs",
|
||||||
|
cidrs: "192.168.1.0/24,10.0.0.0/8",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CIDR with spaces",
|
||||||
|
cidrs: "192.168.1.0/24, 10.0.0.0/8",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare IPv4 address",
|
||||||
|
cidrs: "192.168.1.1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare IPv6 address",
|
||||||
|
cidrs: "fe80::1",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid CIDR",
|
||||||
|
cidrs: "not-a-cidr",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid IP address",
|
||||||
|
cidrs: "999.999.999.999",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := nm.UpdateAllowedPrivateCIDRs(tt.cidrs)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("UpdateAllowedPrivateCIDRs() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsIPInAllowlist(t *testing.T) {
|
||||||
|
nm := NewNotificationManager("")
|
||||||
|
|
||||||
|
// Set up allowlist
|
||||||
|
err := nm.UpdateAllowedPrivateCIDRs("192.168.1.0/24,10.0.0.0/8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to setup allowlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ip string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IP in first CIDR range",
|
||||||
|
ip: "192.168.1.100",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IP in second CIDR range",
|
||||||
|
ip: "10.5.10.20",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IP not in any range",
|
||||||
|
ip: "172.16.1.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IP at network boundary",
|
||||||
|
ip: "192.168.1.0",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IP at broadcast boundary",
|
||||||
|
ip: "192.168.1.255",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
ip := net.ParseIP(tt.ip)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("Invalid test IP: %s", tt.ip)
|
||||||
|
}
|
||||||
|
result := nm.isIPInAllowlist(ip)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("isIPInAllowlist(%s) = %v, expected %v", tt.ip, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsIPInAllowlistEmptyList(t *testing.T) {
|
||||||
|
nm := NewNotificationManager("")
|
||||||
|
|
||||||
|
// Empty allowlist should block all IPs
|
||||||
|
ip := net.ParseIP("192.168.1.1")
|
||||||
|
if nm.isIPInAllowlist(ip) {
|
||||||
|
t.Error("Empty allowlist should block all private IPs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateWebhookURLWithAllowlist(t *testing.T) {
|
||||||
|
nm := NewNotificationManager("")
|
||||||
|
|
||||||
|
// Test without allowlist (should block private IPs)
|
||||||
|
err := nm.ValidateWebhookURL("http://192.168.1.100/webhook")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for private IP without allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up allowlist
|
||||||
|
err = nm.UpdateAllowedPrivateCIDRs("192.168.1.0/24")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to setup allowlist: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should now allow the private IP in the allowlist
|
||||||
|
err = nm.ValidateWebhookURL("http://192.168.1.100/webhook")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected no error for private IP in allowlist, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should still block private IPs not in allowlist
|
||||||
|
err = nm.ValidateWebhookURL("http://10.0.0.1/webhook")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for private IP not in allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should always block localhost regardless of allowlist
|
||||||
|
err = nm.ValidateWebhookURL("http://localhost/webhook")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for localhost even with allowlist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should always block link-local regardless of allowlist
|
||||||
|
err = nm.ValidateWebhookURL("http://169.254.169.254/webhook")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for link-local even with allowlist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -401,7 +401,7 @@ func (n *NotificationManager) sendWebhookOnceWithResponse(webhook EnhancedWebhoo
|
||||||
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0")
|
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0")
|
||||||
|
|
||||||
// Send request with secure client
|
// Send request with secure client
|
||||||
client := createSecureWebhookClient(WebhookTimeout)
|
client := n.createSecureWebhookClient(WebhookTimeout)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -600,7 +600,7 @@ func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig)
|
||||||
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0 (Test)")
|
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0 (Test)")
|
||||||
|
|
||||||
// Send with shorter timeout for testing
|
// Send with shorter timeout for testing
|
||||||
client := createSecureWebhookClient(WebhookTestTimeout)
|
client := n.createSecureWebhookClient(WebhookTestTimeout)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue