mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
c52f64e263
* 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.
66 lines
1.6 KiB
Go
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)
|
|
}
|