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", ) errBlockedMetadata = errors.New( "blocked link-local or cloud instance metadata " + "address: 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: the link-local blocks and the cloud instance metadata // endpoints that live outside them. Reaching one is credential // theft rather than delivery to an internal service, so a // supplied CIDR that covers such an address still leaves it // blocked. // // Some of these are also in blockedNetworks and this list is // what makes them unconditional; two are public unicast and are // reachable by default without it. Every metadata endpoint // outside the link-local range is a /32 or /128 host route, so // blocking it costs an operator nothing else on the surrounding // network. // // Derive membership from the address, never from the vendor's // prose. Several providers call these endpoints "link-local" or // even "localhost" in their own documentation while the address // is a ULA outside fe80::/10, so a set derived from the docs // comes out wrong. // //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", }) // Every entry is named. The set must not grow or shrink // without a matching change to // TestAlwaysBlockedNetworks_PinnedSet. // // The IPv4-mapped form ::ffff:169.254.169.254 needs no // entry: net.IPNet.Contains normalises it via To4() before // comparing, so 169.254.0.0/16 already matches it. To4() // does not normalise the IPv4-compatible or NAT64 forms, // which is why those are listed separately. alwaysBlockedNetworks = mustParseCIDRs([]string{ // IPv4 link-local, carrying the 169.254.169.254 // metadata service used by AWS, Azure, Alibaba, // DigitalOcean, Hetzner, OpenStack and others. "169.254.0.0/16", // IPv6 link-local, its IPv6 counterpart. "fe80::/10", // IPv6 metadata endpoints in ULA space. Each is a host // route, and fd00::/8 is an ordinary block for an // operator to allowlist, so without these entries that // one allowlist line hands out cloud credentials on // every provider below. // // AWS IPv6 IMDS. "fd00:ec2::254/128", // AWS EKS Pod Identity Agent, which issues pod identity // credentials. A second AWS endpoint, distinct from // IMDS above. AWS's own docs call it "localhost". "fd00:ec2::23/128", // GCP metadata server for IPv6-only instances. "fd20:ce::254/128", // Oracle OCI IMDS, serving /opc/v2 instance principals. "fd00:c1::a9fe:a9fe/128", // Scaleway metadata, serving /user_data and /conf. "fd00:42::42/128", // Linode/Akamai metadata. Akamai's docs call it // "link-local"; it is not. "fd00:a9fe:a9fe::1/128", // IPv4 metadata endpoints outside link-local. // // Alibaba Cloud metadata. It sits in CGNAT // 100.64.0.0/10, which Tailscale also uses, so an // operator allowlisting a Tailscale peer's range would // otherwise reopen it. "100.100.100.200/32", // Oracle Cloud Classic metadata. Inside the blocked // 192.0.0.0/24, so this entry is what stops an // allowlist from opening it. "192.0.0.192/32", // Azure WireServer, carrying goalstate and extension // settings on ports 80 and 32526. Public unicast, so // this entry is what blocks it at all. "168.63.129.16/32", // Equinix Metal metadata. Public unicast, likewise. "147.75.207.243/32", // 169.254.169.254 as an IPv4-compatible IPv6 address. "::a9fe:a9fe/128", // 169.254.169.254 behind the NAT64 well-known prefix. "64:ff9b::a9fe:a9fe/128", }) } // 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. alwaysBlockedNetworks is refused before the allowlist is // consulted, so no configured CIDR reaches link-local or a // cloud instance metadata endpoint. // 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, errBlockedMetadata, ) } 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 }