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>
171 lines
3.7 KiB
Go
171 lines
3.7 KiB
Go
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")
|
|
}
|
|
}
|