Files
pad/internal/webhooks/validate.go
T
xarmian c52f64e263 fix(netpolicy): one outbound address policy for URL import and webhooks (#1738)
* fix(urlimport): one address policy for URL import and webhooks (BUG-3358)

URL import refused only loopback, RFC 1918, link-local and CGNAT, so it
reached 198.18.0.0/15, 0.0.0.0/8, 240.0.0.0/4 and the other reserved ranges
webhooks already refused, and the import door returns the fetched body. Both
now call internal/netpolicy.Blocked, checked at dial time against the
address being connected to, which also adds IPv6 forms that embed IPv4
(NAT64, 6to4, IPv4-compatible) and the remaining special-purpose IPv6
blocks. Regression legs cover a literal address, a DNS answer and a
redirect.

* fix(netpolicy): registry gaps, public exceptions, NAT64 by embedded address, malformed input (BUG-3358)

Codex r1: add 192.88.99.0/24, 100:0:0:1::/64, 3fff::/20 and 5f00::/16;
allow the globally reachable exceptions inside 192.0.0.0/24 and 2001::/23;
judge the NAT64 well-known prefix by the IPv4 address it embeds instead of
refusing the prefix; refuse any slice that is not 4 or 16 bytes. The
redirect test now requires the policy refusal, and the dialer's literal
screen has its own leg.
2026-10-02 17:09:12 -04:00

66 lines
1.6 KiB
Go

package webhooks
import (
"fmt"
"net"
"net/url"
"github.com/PerpetualSoftware/pad/internal/netpolicy"
)
// 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 reports whether a webhook may not be delivered to ip. The
// policy is the one shared with URL import (BUG-3358): see netpolicy.Blocked.
func isPrivateIP(ip net.IP) bool {
return netpolicy.Blocked(ip)
}