check / check (push) Waiting to run
Adding or editing an http or slack target whose address is private or reserved was refused with no hint that the refusal is deliberate or that it can be lifted. The refusal now adds that such addresses are refused by default and that the server's ALLOWED_EGRESS_CIDRS setting allows named networks, naming the README section "Allowing egress to your own network". Metadata refusals do not get it. The default blocklist's public addresses move to a list of their own, still checked after the allowlist, and are refused as cloud metadata addresses. The private-and-reserved error is exported as ErrBlockedPrivateOrReservedIP so the handler can tell them apart. Model: opus-5-5
465 lines
13 KiB
Go
465 lines
13 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",
|
|
)
|
|
// ErrBlockedPrivateOrReservedIP reports an address in the
|
|
// default blocklist's private and reserved ranges,
|
|
// blockedNetworks.
|
|
ErrBlockedPrivateOrReservedIP = errors.New(
|
|
"blocked private or reserved address",
|
|
)
|
|
// errBlockedPublicMetadata reports a public address on the
|
|
// default blocklist, one in blockedPublicNetworks.
|
|
errBlockedPublicMetadata = errors.New(
|
|
"blocked cloud metadata address",
|
|
)
|
|
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 and blockedPublicNetworks together are the
|
|
// default blocklist: the private and reserved IP ranges, plus
|
|
// the public cloud metadata addresses, that are blocked to
|
|
// prevent SSRF attacks. An operator can permit specific blocks
|
|
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
|
|
//
|
|
// blockedNetworks holds the private and reserved IP ranges.
|
|
//
|
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
var blockedNetworks []*net.IPNet
|
|
|
|
// blockedPublicNetworks holds the default blocklist's public
|
|
// addresses, kept apart from blockedNetworks so that they are
|
|
// refused as cloud metadata addresses, never as private or
|
|
// reserved ones.
|
|
//
|
|
// A public address belongs on the default blocklist only if it
|
|
// hands credentials, user data or bootstrap material to whatever
|
|
// can reach it, without the caller presenting anything; it goes
|
|
// in this list. A provider's other public addresses are not
|
|
// refused, since reaching them can be legitimate and no list of
|
|
// them could be complete.
|
|
//
|
|
//nolint:gochecknoglobals // package-level network list is appropriate here
|
|
var blockedPublicNetworks []*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 blockedPublicNetworks
|
|
// 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",
|
|
})
|
|
|
|
blockedPublicNetworks = mustParseCIDRs([]string{
|
|
// Azure WireServer, a public address that serves VM credentials.
|
|
"168.63.129.16/32",
|
|
})
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 metadata endpoint at a non-public address.
|
|
// 2. The allowlist is consulted next, so a listed private
|
|
// network, or a listed public address on the default
|
|
// blocklist, 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 matchesAny(blockedNetworks, ip) {
|
|
return fmt.Errorf(
|
|
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
|
|
)
|
|
}
|
|
|
|
if matchesAny(blockedPublicNetworks, ip) {
|
|
return fmt.Errorf(
|
|
"target IP %s: %w", ip, errBlockedPublicMetadata,
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|