434 lines
12 KiB
Go
434 lines
12 KiB
Go
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
|
|
// or user-data theft rather than delivery to an internal
|
|
// service, so a supplied CIDR that covers such an address still
|
|
// leaves it blocked.
|
|
//
|
|
// Inclusion criterion — an address belongs here only if BOTH
|
|
// hold, and every entry below satisfies both:
|
|
//
|
|
// 1. It is a fixed address assigned by the provider, or a
|
|
// range reserved by IANA — never one the operator chose.
|
|
// That is what makes a host route free: it cannot collide
|
|
// with anything the operator runs.
|
|
// 2. Reaching it discloses credentials, or user data or
|
|
// bootstrap material — something granting onward access, or
|
|
// not cheaply rotated.
|
|
//
|
|
// Both halves are load-bearing, so use them to refuse a
|
|
// candidate and say why. An endpoint disclosing only the
|
|
// operator's own inventory (instance id, region, disks, NICs)
|
|
// fails (2): letting a delivery target reach the operator's own
|
|
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
|
// provide. But (2) is not "IAM credentials only" either —
|
|
// fd00:42::42 serves /user_data and /conf rather than tokens,
|
|
// and user data routinely carries bootstrap secrets. An address
|
|
// stays out if it fails (1) however well it clears (2): a host
|
|
// route inside a block operators really assign from, such as
|
|
// 10.0.0.0/8, can collide with a real internal service and
|
|
// forfeits the justification in (1).
|
|
//
|
|
// A publicly routable unicast address does not belong here even
|
|
// when it clears both halves. Nothing in this list can be
|
|
// reopened, so putting a public address here leaves the operator
|
|
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
|
|
// exists to remove. Default-block it in blockedNetworks instead,
|
|
// which an allowlist can override.
|
|
//
|
|
// This is a criterion, not an enumeration of every metadata
|
|
// address in existence.
|
|
//
|
|
// Every entry is either already in blockedNetworks — this list is
|
|
// what makes it unconditional — or an alternate encoding of
|
|
// 169.254.169.254 that Contains does not match against
|
|
// 169.254.0.0/16. Every entry outside the link-local blocks 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, DigitalOcean,
|
|
// Hetzner, OpenStack and others. Not Alibaba, which uses
|
|
// 100.100.100.200 below exclusively.
|
|
"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",
|
|
|
|
// 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
|
|
}
|