Add an egress CIDR allowlist to the SSRF guard (closes #204)
Some checks failed
check / check (push) Failing after 3m9s
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.
This commit is contained in:
@@ -6,8 +6,11 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -25,20 +28,49 @@ var (
|
||||
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.
|
||||
// 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() {
|
||||
cidrs := []string{
|
||||
blockedNetworks = mustParseCIDRs([]string{
|
||||
"127.0.0.0/8",
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
@@ -56,7 +88,77 @@ func init() {
|
||||
"::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)
|
||||
@@ -67,16 +169,15 @@ func init() {
|
||||
))
|
||||
}
|
||||
|
||||
blockedNetworks = append(
|
||||
blockedNetworks, network,
|
||||
)
|
||||
networks = append(networks, network)
|
||||
}
|
||||
|
||||
return networks
|
||||
}
|
||||
|
||||
// isBlockedIP checks whether an IP address falls within
|
||||
// any blocked private/reserved network range.
|
||||
func isBlockedIP(ip net.IP) bool {
|
||||
for _, network := range blockedNetworks {
|
||||
// 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
|
||||
}
|
||||
@@ -85,9 +186,40 @@ func isBlockedIP(ip net.IP) bool {
|
||||
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 ValidateTargetURL(
|
||||
func (g *Guard) ValidateTargetURL(
|
||||
ctx context.Context, targetURL string,
|
||||
) error {
|
||||
parsed, err := url.Parse(targetURL)
|
||||
@@ -111,36 +243,79 @@ func ValidateTargetURL(
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return checkBlockedIP(ip)
|
||||
return g.checkIP(ip)
|
||||
}
|
||||
|
||||
return validateHostname(ctx, host)
|
||||
return g.validateHostname(ctx, host)
|
||||
}
|
||||
|
||||
func validateScheme(scheme string) error {
|
||||
if scheme != "http" && scheme != "https" {
|
||||
// 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(
|
||||
"unsupported URL scheme %q: %w",
|
||||
scheme, errInvalidScheme,
|
||||
"target IP %s: %w", ip, errBlockedMetadata,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
if g.allows(ip) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkBlockedIP(ip net.IP) error {
|
||||
if isBlockedIP(ip) {
|
||||
return fmt.Errorf(
|
||||
"target IP %s is in a blocked "+
|
||||
"private/reserved range: %w",
|
||||
ip, errBlockedIP,
|
||||
"target IP %s: %w", ip, errBlockedIP,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHostname(
|
||||
func (g *Guard) validateHostname(
|
||||
ctx context.Context, host string,
|
||||
) error {
|
||||
dnsCtx, cancel := context.WithTimeout(
|
||||
@@ -165,11 +340,11 @@ func validateHostname(
|
||||
}
|
||||
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
err = g.checkIP(ipAddr.IP)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"hostname %q resolves to blocked "+
|
||||
"IP %s: %w",
|
||||
host, ipAddr.IP, errBlockedIP,
|
||||
"hostname %q resolves to a blocked address: %w",
|
||||
host, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -177,16 +352,7 @@ func validateHostname(
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewSSRFSafeTransport creates an http.Transport with a
|
||||
// custom DialContext that blocks connections to
|
||||
// private/reserved IP addresses.
|
||||
func NewSSRFSafeTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: ssrfDialContext,
|
||||
}
|
||||
}
|
||||
|
||||
func ssrfDialContext(
|
||||
func (g *Guard) ssrfDialContext(
|
||||
ctx context.Context,
|
||||
network, addr string,
|
||||
) (net.Conn, error) {
|
||||
@@ -209,11 +375,11 @@ func ssrfDialContext(
|
||||
}
|
||||
|
||||
for _, ipAddr := range ips {
|
||||
if isBlockedIP(ipAddr.IP) {
|
||||
err = g.checkIP(ipAddr.IP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"ssrf: connection to %s (%s) "+
|
||||
"blocked: %w",
|
||||
host, ipAddr.IP, errBlockedIP,
|
||||
"ssrf: connection to %s blocked: %w",
|
||||
host, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -225,3 +391,14 @@ func ssrfDialContext(
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user