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:
rcourtman 2025-11-09 08:31:12 +00:00
parent 6bb53eaadb
commit 1b221cca71
9 changed files with 352 additions and 11 deletions

View file

@ -644,6 +644,9 @@ const Settings: Component<SettingsProps> = (props) => {
const [allowEmbedding, setAllowEmbedding] = createSignal(false);
const [allowedEmbedOrigins, setAllowedEmbedOrigins] = createSignal('');
// Webhook security settings
const [webhookAllowedPrivateCIDRs, setWebhookAllowedPrivateCIDRs] = createSignal('');
// Update settings
const [versionInfo, setVersionInfo] = createSignal<VersionInfo | null>(null);
const [updateInfo, setUpdateInfo] = createSignal<UpdateInfo | null>(null);
@ -1659,6 +1662,8 @@ const Settings: Component<SettingsProps> = (props) => {
// Load embedding settings
setAllowEmbedding(systemSettings.allowEmbedding ?? false);
setAllowedEmbedOrigins(systemSettings.allowedEmbedOrigins || '');
// Load webhook security settings
setWebhookAllowedPrivateCIDRs(systemSettings.webhookAllowedPrivateCIDRs || '');
setTemperatureMonitoringEnabled(
typeof systemSettings.temperatureMonitoringEnabled === 'boolean'
? systemSettings.temperatureMonitoringEnabled
@ -1775,6 +1780,7 @@ const Settings: Component<SettingsProps> = (props) => {
backupPollingInterval: backupPollingInterval(),
allowEmbedding: allowEmbedding(),
allowedEmbedOrigins: allowedEmbedOrigins(),
webhookAllowedPrivateCIDRs: webhookAllowedPrivateCIDRs(),
});
}
@ -3759,6 +3765,54 @@ const Settings: Component<SettingsProps> = (props) => {
</div>
</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
tone="warning"
padding="sm"

View file

@ -46,6 +46,7 @@ export interface SystemConfig {
discoverySubnet?: string; // Subnet to scan for discovery (default: 'auto')
allowEmbedding?: boolean; // Allow iframe 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")
}
/**

View file

@ -201,7 +201,7 @@ func (h *NotificationHandlers) CreateWebhook(w http.ResponseWriter, r *http.Requ
}
// 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)
return
}
@ -258,7 +258,7 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
}
// 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)
return
}

View file

@ -1172,6 +1172,15 @@ func (r *Router) reloadSystemSettings() {
if systemSettings, err := r.persistence.LoadSystemSettings(); err == nil && systemSettings != nil {
r.cachedAllowEmbedding = systemSettings.AllowEmbedding
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 {
// On error, use safe defaults
r.cachedAllowEmbedding = false

View file

@ -16,6 +16,7 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"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/websocket"
"github.com/rs/zerolog/log"
@ -33,6 +34,7 @@ type SystemSettingsHandler struct {
StopDiscoveryService()
EnableTemperatureMonitoring()
DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager
}
}
@ -43,6 +45,7 @@ func NewSystemSettingsHandler(cfg *config.Config, persistence *config.ConfigPers
StopDiscoveryService()
EnableTemperatureMonitoring()
DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager
}, reloadSystemSettingsFunc func()) *SystemSettingsHandler {
return &SystemSettingsHandler{
config: cfg,
@ -60,6 +63,7 @@ func (h *SystemSettingsHandler) SetMonitor(m interface {
StopDiscoveryService()
EnableTemperatureMonitoring()
DisableTemperatureMonitoring()
GetNotificationManager() *notifications.NotificationManager
}) {
h.monitor = m
}
@ -549,6 +553,10 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if _, ok := rawRequest["allowedEmbedOrigins"]; ok {
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
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
if err := h.persistence.SaveSystemSettings(settings); err != nil {
log.Error().Err(err).Msg("Failed to save system settings")

View file

@ -830,6 +830,7 @@ type SystemSettings struct {
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
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)
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
}

View file

@ -43,7 +43,7 @@ const (
)
// 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
return &http.Client{
Timeout: timeout,
@ -57,7 +57,7 @@ func createSecureWebhookClient(timeout time.Duration) *http.Client {
newURL := req.URL.String()
// Prevent redirects to localhost or private networks (SSRF protection)
if err := ValidateWebhookURL(newURL); err != nil {
if err := n.ValidateWebhookURL(newURL); err != nil {
log.Warn().
Str("original", via[0].URL.String()).
Str("redirect", newURL).
@ -126,6 +126,8 @@ type NotificationManager struct {
queue *NotificationQueue // Persistent notification queue
webhookClient *http.Client // Shared HTTP client for webhooks
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)
@ -353,10 +355,12 @@ func NewNotificationManager(publicURL string) *NotificationManager {
publicURL: cleanURL,
appriseExec: defaultAppriseExec,
queue: queue,
webhookClient: createSecureWebhookClient(WebhookTimeout),
stopCleanup: make(chan struct{}),
}
// Create webhook client after NotificationManager is initialized
nm.webhookClient = nm.createSecureWebhookClient(WebhookTimeout)
// Wire up queue processor if queue is available
if queue != nil {
queue.SetProcessor(nm.ProcessQueuedNotification)
@ -953,7 +957,7 @@ func (n *NotificationManager) sendAppriseViaHTTP(cfg AppriseConfig, title, body,
}
// Validate Apprise server URL to prevent SSRF
if err := ValidateWebhookURL(serverURL); err != nil {
if err := n.ValidateWebhookURL(serverURL); err != nil {
log.Error().
Err(err).
Str("serverURL", serverURL).
@ -1445,7 +1449,7 @@ func (n *NotificationManager) checkWebhookRateLimit(webhookURL string) bool {
// sendWebhookRequest sends the actual webhook request
func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData []byte, alertType string) error {
// 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().
Err(err).
Str("webhook", webhook.Name).
@ -1974,7 +1978,7 @@ func isNumeric(s string) bool {
}
// 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 == "" {
return fmt.Errorf("webhook URL cannot be empty")
}
@ -2015,7 +2019,15 @@ func ValidateWebhookURL(webhookURL string) error {
// Check all resolved IPs for private ranges
for _, ip := range ips {
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
}
// 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
func (n *NotificationManager) addWebhookDelivery(delivery WebhookDelivery) {
n.mu.Lock()

View 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")
}
}

View file

@ -401,7 +401,7 @@ func (n *NotificationManager) sendWebhookOnceWithResponse(webhook EnhancedWebhoo
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0")
// Send request with secure client
client := createSecureWebhookClient(WebhookTimeout)
client := n.createSecureWebhookClient(WebhookTimeout)
resp, err := client.Do(req)
if err != nil {
@ -600,7 +600,7 @@ func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig)
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0 (Test)")
// Send with shorter timeout for testing
client := createSecureWebhookClient(WebhookTestTimeout)
client := n.createSecureWebhookClient(WebhookTestTimeout)
resp, err := client.Do(req)
if err != nil {