Files
NianAIGC/backend/internal/webhook/destination.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"),
}