Some checks failed
check / check (push) Failing after 3m9s
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, the guard behaves exactly as before. A fixed 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. It is the two link-local blocks (169.254.0.0/16, fe80::/10) plus host routes for the cloud metadata endpoints that sit outside them: AWS's IPv6 IMDS at fd00:ec2::254, which lives in ordinary ULA space, and Alibaba's 100.100.100.200, which lives in CGNAT. Allowlisting fd00::/8 or 100.64.0.0/10 (Tailscale's range) is an ordinary thing for an operator to do and must not reopen instance-credential theft. The IPv4-compatible (::a9fe:a9fe) and NAT64 (64:ff9b::a9fe:a9fe) spellings of 169.254.169.254 are listed too, because To4() does not normalise them into the link-local block the way it does the IPv4-mapped form. Reaching any of these is credential theft rather than delivery to an internal service. 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.
405 lines
10 KiB
Go
405 lines
10 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
|
|
// 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
|
|
}
|