package delivery import ( "context" "errors" "fmt" "net" "net/http" "net/netip" "net/url" "time" "sneak.berlin/go/webhooker/internal/config" ) const ( // dnsResolutionTimeout is the maximum time to wait for // DNS resolution during SSRF validation. dnsResolutionTimeout = 5 * time.Second ) // Sentinel errors for SSRF validation. var ( errNoHostname = errors.New("URL has no hostname") errNoIPs = errors.New( "hostname resolved to no IP addresses", ) errBlockedIP = errors.New( "blocked private/reserved IP range", ) errBlockedLinkLocal = errors.New( "blocked link-local range, which serves cloud instance " + "metadata: ALLOWED_EGRESS_CIDRS cannot open it", ) errInvalidScheme = errors.New( "only http and https are allowed", ) ) // blockedNetworks contains all private/reserved IP ranges // that should be blocked to prevent SSRF attacks. An operator // can permit specific blocks out of this set with // ALLOWED_EGRESS_CIDRS; see Guard. // //nolint:gochecknoglobals // package-level network list is appropriate here var blockedNetworks []*net.IPNet // alwaysBlockedNetworks are the ranges no configuration can // open. They are the link-local blocks, which carry the cloud // instance metadata services (169.254.169.254 and its IPv6 // equivalents). Reaching one is credential theft rather than // delivery to an internal service, so a supplied CIDR that // covers a link-local address still leaves it blocked. // // These addresses are also in blockedNetworks; this list is what // makes them unconditional. // //nolint:gochecknoglobals // package-level network list is appropriate here var alwaysBlockedNetworks []*net.IPNet //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup func init() { blockedNetworks = mustParseCIDRs([]string{ "127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16", "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4", "::1/128", "fc00::/7", "fe80::/10", }) alwaysBlockedNetworks = mustParseCIDRs([]string{ "169.254.0.0/16", "fe80::/10", }) } // mustParseCIDRs parses a list of CIDR literals, panicking on a // bad one. The inputs are compile-time constants, so a failure // is a programming error rather than a runtime condition. func mustParseCIDRs(cidrs []string) []*net.IPNet { networks := make([]*net.IPNet, 0, len(cidrs)) for _, cidr := range cidrs { _, network, err := net.ParseCIDR(cidr) if err != nil { panic(fmt.Sprintf( "ssrf: failed to parse CIDR %q: %v", cidr, err, )) } networks = append(networks, network) } return networks } // matchesAny reports whether ip falls inside any of networks. func matchesAny(networks []*net.IPNet, ip net.IP) bool { for _, network := range networks { if network.Contains(ip) { return true } } return false } // isBlockedIP checks whether an IP address falls within // any blocked private/reserved network range, before any // operator allowlist is considered. func isBlockedIP(ip net.IP) bool { return matchesAny(blockedNetworks, ip) } // Guard makes every SSRF decision in the process. // // It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and // applies it in exactly one place, checkIP, which both the // target-creation validator (ValidateTargetURL) and the delivery // dialer call. Routing both through the same function is the // point: when the two paths decided separately they drifted and // disagreed, which is what made a target creatable but // undeliverable. // // The guard is always on. The allowlist only ever adds specific // networks to what the default blocklist refuses, and no // configuration turns the guard off wholesale. type Guard struct { // allowed is the operator's ALLOWED_EGRESS_CIDRS. Empty // (the default) means the default blocklist stands as-is. allowed []netip.Prefix } // NewGuard builds the process-wide SSRF guard from configuration. func NewGuard(cfg *config.Config) *Guard { return &Guard{allowed: cfg.AllowedEgressCIDRs} } // ValidateTargetURL checks that an HTTP delivery target // URL is safe from SSRF attacks. func (g *Guard) ValidateTargetURL( ctx context.Context, targetURL string, ) error { parsed, err := url.Parse(targetURL) if err != nil { // url.Parse embeds the whole URL in its error, and // this one is logged and shown; mask it. Every other // branch below reports only the hostname. return fmt.Errorf( "invalid URL: %w", maskURLError(err), ) } err = validateScheme(parsed.Scheme) if err != nil { return err } host := parsed.Hostname() if host == "" { return errNoHostname } if ip := net.ParseIP(host); ip != nil { return g.checkIP(ip) } return g.validateHostname(ctx, host) } // NewSSRFSafeTransport creates an http.Transport with a // custom DialContext that refuses connections to any address // this guard blocks. It resolves and checks at dial time, so a // name that passed validation but now answers with a blocked // address (DNS rebinding) is still refused. func (g *Guard) NewSSRFSafeTransport() *http.Transport { return &http.Transport{ DialContext: g.ssrfDialContext, } } // allows reports whether ip falls inside the operator's // configured egress allowlist. func (g *Guard) allows(ip net.IP) bool { if len(g.allowed) == 0 { return false } addr, ok := netip.AddrFromSlice(ip) if !ok { return false } // Config unmaps every parsed prefix, so an IPv4-mapped // address has to be unmapped too or it would never match. addr = addr.Unmap() for _, prefix := range g.allowed { if prefix.Contains(addr) { return true } } return false } // checkIP is the single point at which SSRF policy is decided. // // The order is the policy: // // 1. Link-local is refused before the allowlist is consulted, // so no configured CIDR can reach cloud instance metadata. // 2. The allowlist is consulted next, so a listed private // network becomes reachable. // 3. Everything else keeps the default blocklist's answer. func (g *Guard) checkIP(ip net.IP) error { if matchesAny(alwaysBlockedNetworks, ip) { return fmt.Errorf( "target IP %s: %w", ip, errBlockedLinkLocal, ) } if g.allows(ip) { return nil } if isBlockedIP(ip) { return fmt.Errorf( "target IP %s: %w", ip, errBlockedIP, ) } return nil } func (g *Guard) validateHostname( ctx context.Context, host string, ) error { dnsCtx, cancel := context.WithTimeout( ctx, dnsResolutionTimeout, ) defer cancel() ips, err := net.DefaultResolver.LookupIPAddr( dnsCtx, host, ) if err != nil { return fmt.Errorf( "failed to resolve hostname %q: %w", host, err, ) } if len(ips) == 0 { return fmt.Errorf( "hostname %q: %w", host, errNoIPs, ) } for _, ipAddr := range ips { err = g.checkIP(ipAddr.IP) if err != nil { return fmt.Errorf( "hostname %q resolves to a blocked address: %w", host, err, ) } } return nil } func (g *Guard) ssrfDialContext( ctx context.Context, network, addr string, ) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, fmt.Errorf( "ssrf: invalid address %q: %w", addr, err, ) } ips, err := net.DefaultResolver.LookupIPAddr( ctx, host, ) if err != nil { return nil, fmt.Errorf( "ssrf: DNS resolution failed for %q: %w", host, err, ) } for _, ipAddr := range ips { err = g.checkIP(ipAddr.IP) if err != nil { return nil, fmt.Errorf( "ssrf: connection to %s blocked: %w", host, err, ) } } var dialer net.Dialer return dialer.DialContext( ctx, network, net.JoinHostPort(ips[0].IP.String(), port), ) } func validateScheme(scheme string) error { if scheme != "http" && scheme != "https" { return fmt.Errorf( "unsupported URL scheme %q: %w", scheme, errInvalidScheme, ) } return nil }