Files
pad/internal/webhooks/validate.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

110 lines
3.3 KiB
Go

package webhooks
import (
"fmt"
"net"
"net/url"
)
// ValidateWebhookURL checks that a webhook URL is safe to call.
// It rejects non-HTTP(S) schemes, URLs with credentials, private/reserved
// IPs (loopback, link-local, RFC1918, cloud metadata), and hostnames that
// resolve to private IPs.
func ValidateWebhookURL(rawURL string) error {
u, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Scheme must be http or https
switch u.Scheme {
case "http", "https":
// ok
default:
return fmt.Errorf("unsupported scheme %q: only http and https are allowed", u.Scheme)
}
// Reject URLs with embedded credentials
if u.User != nil {
return fmt.Errorf("URLs with embedded credentials are not allowed")
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("URL must have a hostname")
}
// Check if host is a literal IP
if ip := net.ParseIP(host); ip != nil {
if isPrivateIP(ip) {
return fmt.Errorf("webhook URLs must not target private or reserved IP addresses")
}
return nil
}
// Host is a name — resolve it and check all resulting IPs
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("failed to resolve hostname %q: %w", host, err)
}
for _, ip := range ips {
if isPrivateIP(ip) {
return fmt.Errorf("hostname %q resolves to private/reserved IP %s", host, ip)
}
}
return nil
}
// isPrivateIP returns true if the IP is in a private, reserved, or
// otherwise non-routable range. It covers loopback (127.0.0.0/8, ::1),
// RFC1918 (10/8, 172.16/12, 192.168/16) and IPv6 unique-local (fc00::/7)
// via the stdlib predicates, link-local (169.254.0.0/16 — which catches
// the AWS/GCP/Azure metadata IP 169.254.169.254 — and fe80::/10), all
// multicast (224.0.0.0/4, ff00::/8), the unspecified address, and the
// extra reserved ranges in reservedRanges below.
func isPrivateIP(ip net.IP) bool {
if ip.IsLoopback() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified() ||
ip.IsPrivate() {
return true
}
for _, cidr := range reservedRanges {
if cidr.Contains(ip) {
return true
}
}
return false
}
// reservedRanges are additional blocks that are not routable on the public
// internet but are not caught by the stdlib predicates above. Precomputed
// at init so isPrivateIP stays allocation-free and concurrency-safe.
var reservedRanges = mustParseCIDRs(
"0.0.0.0/8", // "this network" (RFC 1122) — some stacks route it locally
"100.64.0.0/10", // CGNAT (RFC 6598)
"192.0.0.0/24", // IETF protocol assignments (RFC 6890)
"198.18.0.0/15", // benchmarking (RFC 2544)
"240.0.0.0/4", // reserved / Class E (also contains 255.255.255.255)
"255.255.255.255/32", // limited broadcast
"192.0.2.0/24", // TEST-NET-1 documentation (RFC 5737)
"198.51.100.0/24", // TEST-NET-2 documentation (RFC 5737)
"203.0.113.0/24", // TEST-NET-3 documentation (RFC 5737)
"2001:db8::/32", // IPv6 documentation (RFC 3849)
)
func mustParseCIDRs(networks ...string) []*net.IPNet {
out := make([]*net.IPNet, 0, len(networks))
for _, n := range networks {
_, cidr, err := net.ParseCIDR(n)
if err != nil {
panic(fmt.Errorf("webhooks: parse reserved CIDR %q: %w", n, err))
}
out = append(out, cidr)
}
return out
}