Files
pad/internal/webhooks/validate_test.go
T
xarmian ce46b6f190 fix(webhooks): enforce SSRF guard at dial time + widen deny-list (#836)
The webhook SSRF guard only validated the literal URL string at parse
time, so HTTP 302 redirects and DNS rebinding both reached internal IPs
(cloud metadata, RFC1918, docker services).

Enforce the guard at connect time: the delivery client's dialer Control
callback re-checks the actual resolved IP before the socket connects
(closing the DNS-rebind TOCTOU), and CheckRedirect re-runs
ValidateWebhookURL on every hop with a redirect cap. Proxy is pinned nil
so HTTP(S)_PROXY can't bypass the dialer's check.

Widen isPrivateIP to also deny CGNAT (100.64/10), IETF protocol
assignments (192.0.0/24), benchmarking (198.18/15), Class E (240/4),
broadcast, all multicast (224/4, ff00::/8), and the TEST-NET / IPv6
documentation ranges.

Fixes BUG-1993.

Claude-Session: https://claude.ai/code/session_01BoPkYhKqMiWPYmxQigeWsA
2026-07-07 15:23:25 -04:00

120 lines
3.9 KiB
Go

package webhooks
import (
"net"
"testing"
)
func TestValidateWebhookURL(t *testing.T) {
tests := []struct {
name string
url string
wantErr bool
}{
// Valid URLs
{"valid https", "https://example.com/webhook", false},
{"valid http", "http://example.com/callback", false},
{"valid with port", "https://example.com:8080/hook", false},
{"valid with path", "https://example.com/api/v1/webhook", false},
// Invalid schemes
{"ftp scheme", "ftp://example.com/hook", true},
{"javascript scheme", "javascript:alert(1)", true},
{"file scheme", "file:///etc/passwd", true},
{"no scheme", "example.com/hook", true},
// Embedded credentials
{"with credentials", "https://user:pass@example.com/hook", true},
// Private IPs
{"loopback IPv4", "http://127.0.0.1/hook", true},
{"loopback IPv6", "http://[::1]/hook", true},
{"private 10.x", "http://10.0.0.1/hook", true},
{"private 172.16.x", "http://172.16.0.1/hook", true},
{"private 192.168.x", "http://192.168.1.1/hook", true},
{"cloud metadata", "http://169.254.169.254/latest/meta-data/", true},
{"link-local", "http://169.254.1.1/hook", true},
{"unspecified", "http://0.0.0.0/hook", true},
// Widened reserved ranges
{"cgnat 100.64.x", "http://100.64.1.2/hook", true},
{"ietf protocol 192.0.0.x", "http://192.0.0.8/hook", true},
{"benchmarking 198.18.x", "http://198.19.0.1/hook", true},
{"reserved class E 240.x", "http://240.0.0.1/hook", true},
{"broadcast", "http://255.255.255.255/hook", true},
// Public addresses just outside the widened ranges must still pass
{"public above cgnat", "http://100.128.0.1/hook", false},
{"public above 198.18/15", "http://198.20.0.1/hook", false},
// Hostnames resolving to private IPs
{"localhost", "http://localhost/hook", true},
// Empty/invalid
{"empty url", "", true},
{"no host", "http:///path", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateWebhookURL(tt.url)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateWebhookURL(%q) error = %v, wantErr = %v", tt.url, err, tt.wantErr)
}
})
}
}
func TestIsPrivateIP(t *testing.T) {
tests := []struct {
ip string
private bool
}{
{"127.0.0.1", true},
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.0.1", true},
{"169.254.169.254", true},
{"0.0.0.0", true},
{"::1", true},
{"8.8.8.8", false},
{"1.1.1.1", false},
{"93.184.216.34", false},
// Widened reserved ranges
{"100.64.0.1", true}, // CGNAT lower bound
{"100.127.255.254", true}, // CGNAT upper bound
{"192.0.0.1", true}, // IETF protocol assignments
{"198.18.0.1", true}, // benchmarking lower bound
{"198.19.255.254", true}, // benchmarking upper bound
{"240.0.0.1", true}, // reserved / Class E
{"255.255.255.255", true}, // limited broadcast
{"0.0.0.1", true}, // "this network" (RFC 1122), not just 0.0.0.0
{"0.255.255.255", true}, // "this network" upper bound
{"224.0.0.1", true}, // IPv4 multicast
{"239.1.2.3", true}, // IPv4 multicast (admin-scoped)
{"ff02::1", true}, // IPv6 multicast
{"192.0.2.1", true}, // TEST-NET-1 documentation
{"198.51.100.1", true}, // TEST-NET-2 documentation
{"203.0.113.1", true}, // TEST-NET-3 documentation
{"2001:db8::1", true}, // IPv6 documentation
// Just outside the widened ranges — must stay public
{"100.128.0.1", false}, // above CGNAT
{"192.0.1.1", false}, // above 192.0.0.0/24
{"198.20.0.1", false}, // above 198.18.0.0/15
{"223.255.255.255", false}, // last public /8 below the 240/4 reserved block
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
ip := net.ParseIP(tt.ip)
if ip == nil {
t.Fatalf("failed to parse IP: %s", tt.ip)
}
got := isPrivateIP(ip)
if got != tt.private {
t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private)
}
})
}
}