mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-11 21:39:01 +00:00
ce46b6f190
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
110 lines
3.3 KiB
Go
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
|
|
}
|