Files
webhooker/internal/delivery/ssrf.go
sneak a969657536
All checks were successful
check / check (push) Successful in 3m3s
Add an egress CIDR allowlist to the SSRF guard (closes #204)
The SSRF blocklist had no escape hatch, so the thing webhooker is
mostly for — taking a public webhook and forwarding it to something
on your own network — could not be configured at all. Every private
address, Docker sibling and loopback service was permanently
unreachable as a delivery destination.

ALLOWED_EGRESS_CIDRS (default empty) names blocks that delivery
targets may reach despite the default blocklist. It is an allowlist
and only ever adds destinations: there is no boolean, and no value
disables SSRF protection wholesale. Empty, it adds nothing and the
guard permits and refuses the same addresses it did before, save
the two spellings named below.

A small set of addresses is refused before the allowlist is
consulted, so no supplied CIDR opens one — not the exact address,
not a supernet, not 0.0.0.0/0 or ::/0. alwaysBlockedNetworks in
internal/delivery/ssrf.go is the authoritative list and states the
membership criterion in full; it is deliberately not copied here,
because a copy drifts out of date. In short: the provider fixes the
address, so a host route for it collides with nothing the operator
runs, and reaching it discloses credentials or user data. A
publicly routable address never qualifies however well it meets
both — nothing in this set can be reopened, so blocking one here
would leave the operator no escape hatch at all. Those belong in
blockedNetworks, which an allowlist can override.

Every entry is already inside the default blocklist, which is what
makes it unconditional rather than newly blocked, with two
exceptions that are the one behaviour change visible when the
allowlist is unset: ::a9fe:a9fe and 64:ff9b::a9fe:a9fe, the
IPv4-compatible and NAT64 spellings of 169.254.169.254, were
reachable before and are refused now. net.IPNet.Contains normalises
only the IPv4-mapped form via To4(), so 169.254.0.0/16 never
matched those two. The ten it does cover now report a metadata
error rather than the generic private-range one.

The policy now lives in one function, Guard.checkIP, which both
target-creation validation and the delivery dialer call. The two
paths previously decided separately, which is how they came to
disagree about a destination. The guard is built once from config
and injected via fx into both the handlers and the delivery engine,
so there is a single instance and a single answer.

A set-but-unparseable value aborts startup naming the variable,
reusing the existing envPrefixList parser. A non-empty list is
logged at startup with the blocks spelled out, not counted, so the
hole is visible in the log of any deployment that has one.

Tests: an allowlisted loopback CIDR both validates and delivers to a
live server (and the same URL still fails without the allowlist); a
private address outside the listed block stays refused on both
paths; every unconditionally blocked address stays refused on both
paths under an allowlist that covers it, and the set itself is
pinned entry by entry; public addresses are unaffected either way;
and config coverage for parsing, startup abort, and the warning's
contents.
2026-08-20 08:27:32 +00:00

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
}