140 lines
4.6 KiB
Go
140 lines
4.6 KiB
Go
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"),
|
|
}
|