package webhooks import ( "errors" "fmt" "net" "net/http" "net/url" "strings" "syscall" "time" ) // Webhook URLs are attacker-supplied: any authenticated user can register one // and then make the appview dial it on demand with the Test button. Without a // destination guard that is a server-side request forgery primitive against // everything the appview host can reach, including the cloud metadata endpoint // at 169.254.169.254 and anything bound to loopback. // // The guard has two layers: // // 1. ValidateWebhookURL, applied at creation time and again before every // delivery attempt. It enforces https and rejects URLs that name a // non-public IP literal. Its real job is giving the user an immediate, // comprehensible error instead of a mysterious delivery failure later. // // 2. A net.Dialer Control hook on the delivery HTTP client, which inspects the // *resolved* address on every connection attempt. This is the layer that // actually holds: the attacker controls DNS for their own hostname, so a // name that resolves public at creation can resolve to 127.0.0.1 at // delivery (DNS rebinding), and creation-time validation cannot see a // redirect target at all. // // Both the scheduled dispatcher and the synchronous Test button deliver through // attemptDelivery, which uses the guarded client, so neither path can dial a // private address. // User-facing validation failures. The messages are written to be shown // verbatim in the settings UI after an "Invalid webhook URL: " prefix. var ( // ErrMalformedURL means the string did not parse as an absolute URL. ErrMalformedURL = errors.New("could not be parsed as a URL") // ErrSchemeNotHTTPS means the URL used something other than https. // Webhook payloads carry repository names and HMAC signatures, so plaintext // http is refused outright rather than merely discouraged. ErrSchemeNotHTTPS = errors.New("must be https") // ErrPrivateAddress means the URL resolved to, or literally named, an // address that is not publicly routable. ErrPrivateAddress = errors.New("cannot point at a private or loopback address") ) // extraBlocked covers ranges that have no net.IP helper. Everything with a // stdlib predicate (loopback, RFC1918 + IPv6 ULA, link-local, multicast, // unspecified, broadcast) is handled by blockedReason instead of being // re-derived here. var extraBlocked = []struct { net *net.IPNet reason string }{ {mustCIDR("100.64.0.0/10"), "carrier-grade NAT address"}, {mustCIDR("192.0.0.0/24"), "IETF protocol assignment address"}, {mustCIDR("198.18.0.0/15"), "benchmarking address"}, {mustCIDR("240.0.0.0/4"), "reserved address"}, // IPv4-compatible IPv6 (::a.b.c.d). To4 does not unmap these, and // IsLoopback on ::127.0.0.1 is false, so they need an explicit range. {mustCIDR("::/96"), "IPv4-compatible IPv6 address"}, {mustCIDR("64:ff9b::/96"), "NAT64 address"}, {mustCIDR("64:ff9b:1::/48"), "local-use NAT64 address"}, {mustCIDR("100::/64"), "discard-only address"}, {mustCIDR("2001:db8::/32"), "documentation address"}, } func mustCIDR(s string) *net.IPNet { _, n, err := net.ParseCIDR(s) if err != nil { panic("webhooks: bad CIDR " + s + ": " + err.Error()) } return n } // blockedReason reports why an address must not be dialed, or "" if it is a // public destination. IPv4-mapped IPv6 forms (::ffff:127.0.0.1) are unmapped // first, so every IPv4 rule below applies to them too. func blockedReason(ip net.IP) string { if ip == nil { return "not a valid IP address" } if v4 := ip.To4(); v4 != nil { ip = v4 } switch { case ip.IsUnspecified(): return "unspecified address" case ip.IsLoopback(): // 127.0.0.0/8 and ::1 return "loopback address" case ip.IsPrivate(): // 10/8, 172.16/12, 192.168/16 and IPv6 unique-local fc00::/7 return "private address" case ip.IsLinkLocalUnicast(): // 169.254/16 (covers the 169.254.169.254 metadata endpoint) and fe80::/10 return "link-local address" case ip.IsInterfaceLocalMulticast(), ip.IsLinkLocalMulticast(), ip.IsMulticast(): return "multicast address" case !ip.IsGlobalUnicast(): // Catches the IPv4 broadcast address and anything else the stdlib does // not consider globally routable. return "non-global address" } for _, b := range extraBlocked { if b.net.Contains(ip) { return b.reason } } return "" } // ValidateWebhookURL checks a user-supplied webhook URL at rest: https scheme, // a host, and no non-public IP literal. It does not resolve DNS. A hostname // that resolves to a private address is caught at dial time by the Control hook // instead, which is the only check an attacker who controls DNS cannot dodge. func ValidateWebhookURL(raw string) error { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return ErrMalformedURL } if !strings.EqualFold(u.Scheme, "https") { return ErrSchemeNotHTTPS } return validateParsedURL(u) } // validateParsedURL is the scheme + host check applied both at creation and to // every redirect hop. func validateParsedURL(u *url.URL) error { if !strings.EqualFold(u.Scheme, "https") { return ErrSchemeNotHTTPS } host := u.Hostname() if host == "" { return ErrMalformedURL } if ip := net.ParseIP(host); ip != nil { if reason := blockedReason(ip); reason != "" { return fmt.Errorf("%w (%s is a %s)", ErrPrivateAddress, host, reason) } return nil } // Names are resolved at dial time, but reject the obvious ones up front so // the user gets a real message instead of a delivery that just never works. lower := strings.ToLower(host) if lower == "localhost" || strings.HasSuffix(lower, ".localhost") { return fmt.Errorf("%w (%s is a loopback name)", ErrPrivateAddress, host) } return nil } // checkDialAddr inspects a resolved "host:port" address just before the socket // is connected. This runs for every attempt, every redirect hop, and every // address the resolver returns, which is what makes DNS rebinding ineffective. func checkDialAddr(network, address string) error { switch network { case "tcp", "tcp4", "tcp6": default: return fmt.Errorf("%w (network %q is not allowed)", ErrPrivateAddress, network) } host, _, err := net.SplitHostPort(address) if err != nil { host = address } ip := net.ParseIP(host) if ip == nil { // Control receives an already-resolved literal. Anything else is // unexpected, so refuse rather than guess. return fmt.Errorf("%w (%q did not resolve to an IP)", ErrPrivateAddress, address) } if reason := blockedReason(ip); reason != "" { return fmt.Errorf("%w (%s is a %s)", ErrPrivateAddress, ip, reason) } return nil } // safeDialControl is the net.Dialer Control hook. The signature is fixed by // net.Dialer. func safeDialControl(network, address string, _ syscall.RawConn) error { return checkDialAddr(network, address) } // maxRedirects bounds how far a webhook endpoint can bounce us. Each hop is // re-validated, and the dial guard applies to every hop regardless. const maxRedirects = 3 // NewSafeClient builds the HTTP client used for delivering webhook payloads to // user-supplied URLs. It must not be reused for talking to holds or any other // internal service, which may legitimately live on a private address. func NewSafeClient(timeout time.Duration) *http.Client { dialer := &net.Dialer{ Timeout: 5 * time.Second, KeepAlive: 30 * time.Second, Control: safeDialControl, } transport := &http.Transport{ // Deliberately no Proxy: honoring HTTP(S)_PROXY would route the request // through a proxy that the Control hook cannot see past, handing the // bypass straight back. Proxy: nil, DialContext: dialer.DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 10, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: timeout, ExpectContinueTimeout: 1 * time.Second, } return &http.Client{ Timeout: timeout, Transport: transport, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } // A public https endpoint that 302s to http://169.254.169.254/ is // refused here on scheme alone; the dial guard would refuse the // address anyway. if err := validateParsedURL(req.URL); err != nil { return fmt.Errorf("refusing webhook redirect to %s: %w", req.URL.Redacted(), err) } return nil }, } }