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 [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"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
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")
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue