package webhook import ( "context" "errors" "net" "net/netip" "net/url" "strings" ) // IPResolver is the DNS seam used by PublicDestinationPolicy. type IPResolver interface { LookupIPAddr(context.Context, string) ([]net.IPAddr, error) } // ContextDialer is the network seam used after DNS answers have been checked. type ContextDialer interface { DialContext(context.Context, string, string) (net.Conn, error) } // DestinationPolicy validates an outbound HTTP destination. type DestinationPolicy interface { Validate(context.Context, *url.URL) error } // PublicDestinationValidator allows only HTTP(S) destinations whose complete DNS // answer set contains public unicast addresses. DialContext resolves and dials // one of those checked addresses directly, avoiding a second system DNS lookup. type PublicDestinationValidator struct { resolver IPResolver dialer ContextDialer } func NewPublicDestinationPolicy(resolver IPResolver, dialer ContextDialer) *PublicDestinationValidator { if resolver == nil { resolver = net.DefaultResolver } if dialer == nil { dialer = &net.Dialer{} } return &PublicDestinationValidator{resolver: resolver, dialer: dialer} } // PublicDestinationPolicy is the compatibility validator factory used by // callers that provide their own bounded http.Client. func PublicDestinationPolicy(resolver IPResolver) func(context.Context, *url.URL) error { return NewPublicDestinationPolicy(resolver, nil).Validate } func (policy *PublicDestinationValidator) Validate(ctx context.Context, target *url.URL) error { _, err := policy.resolve(ctx, target) return err } func (policy *PublicDestinationValidator) DialContext(ctx context.Context, network, address string) (net.Conn, error) { host, port, err := net.SplitHostPort(address) if err != nil { return nil, errors.New("invalid outbound address") } target := &url.URL{Scheme: "http", Host: net.JoinHostPort(host, port)} ips, err := policy.resolve(ctx, target) if err != nil { return nil, err } return policy.dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port)) } func (policy *PublicDestinationValidator) resolve(ctx context.Context, target *url.URL) ([]net.IP, error) { if target == nil || (target.Scheme != "http" && target.Scheme != "https") || target.Host == "" || target.User != nil { return nil, errors.New("outbound destination is invalid") } host := strings.TrimSuffix(strings.ToLower(target.Hostname()), ".") if host == "" || host == "localhost" || strings.HasSuffix(host, ".localhost") { return nil, errors.New("outbound destination is not public") } var ips []net.IP if literal := net.ParseIP(host); literal != nil { ips = []net.IP{literal} } else { answers, err := policy.resolver.LookupIPAddr(ctx, host) if err != nil || len(answers) == 0 { return nil, errors.New("outbound destination DNS lookup failed") } for _, answer := range answers { ips = append(ips, answer.IP) } } for _, ip := range ips { if !isPublicUnicastIP(ip) { return nil, errors.New("outbound destination is not public") } } return ips, nil } func isPublicUnicastIP(ip net.IP) bool { if ip == nil || !ip.IsGlobalUnicast() || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() { return false } address, ok := netip.AddrFromSlice(ip) if !ok { return false } address = address.Unmap() for _, prefix := range nonPublicSpecialUsePrefixes { if prefix.Contains(address) { return false } } return true } // IsGlobalUnicast deliberately includes several IANA special-purpose ranges. // These networks are not valid public callback destinations even though the Go // standard library does not classify all of them as private or link-local. var nonPublicSpecialUsePrefixes = []netip.Prefix{ netip.MustParsePrefix("0.0.0.0/8"), netip.MustParsePrefix("100.64.0.0/10"), netip.MustParsePrefix("192.0.0.0/29"), netip.MustParsePrefix("192.0.0.170/31"), netip.MustParsePrefix("192.0.2.0/24"), netip.MustParsePrefix("192.88.99.0/24"), netip.MustParsePrefix("198.18.0.0/15"), netip.MustParsePrefix("198.51.100.0/24"), netip.MustParsePrefix("203.0.113.0/24"), netip.MustParsePrefix("240.0.0.0/4"), netip.MustParsePrefix("64:ff9b::/96"), netip.MustParsePrefix("64:ff9b:1::/48"), netip.MustParsePrefix("100::/64"), netip.MustParsePrefix("2001::/32"), netip.MustParsePrefix("2001:2::/48"), netip.MustParsePrefix("2001:10::/28"), netip.MustParsePrefix("2001:20::/28"), netip.MustParsePrefix("2001:db8::/32"), netip.MustParsePrefix("2002::/16"), netip.MustParsePrefix("3fff::/20"), netip.MustParsePrefix("5f00::/16"), }