diff --git a/README.md b/README.md index 7f44357..4619b6b 100644 --- a/README.md +++ b/README.md @@ -158,19 +158,20 @@ WireServer, which serves an Azure VM its credentials. Because it is a public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. That is all the default blocklist covers: the IPv4 private and reserved -ranges; of IPv6, only loopback (`::1`), unique local addresses -(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public -addresses. 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. A provider's other public -addresses are not refused. IBM Cloud, for example, serves its package -mirrors, time servers and object storage on `161.26.0.0/16`, and the -private endpoints of its own cloud services on `166.8.0.0/14`. Neither -range hands out credentials that way: the token service among those -endpoints issues a token only in exchange for something the caller -presents, such as an API key. Reaching these services can be a -legitimate delivery, and every cloud has some, so a partial list would -promise coverage it does not give. +ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`), +unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`), +multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and +certain public addresses. 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. A +provider's other public addresses are not refused. IBM Cloud, for +example, serves its package mirrors, time servers and object storage on +`161.26.0.0/16`, and the private endpoints of its own cloud services on +`166.8.0.0/14`. Neither range hands out credentials that way: the token +service among those endpoints issues a token only in exchange for +something the caller presents, such as an API key. Reaching these +services can be a legitimate delivery, and every cloud has some, so a +partial list would promise coverage it does not give. That default is also inconvenient for the thing webhooker is mostly for: taking a public webhook and forwarding it to something on your own @@ -210,16 +211,16 @@ Two things this setting cannot do: the list is always an allowlist; an empty list (the default) means every private and reserved range stays refused. Note that `0.0.0.0/0` gets you most of the way there anyway, per above. -- **It cannot open link-local, or a cloud metadata endpoint at a - non-public address that discloses credentials or user data.** An - address is on the list below when it is not a public address and both - of these hold: the provider fixes it, so it cannot collide with - anything you run; and reaching it hands out credentials, user data or - bootstrap material. Those stay blocked no matter what you list, - including when you list them outright or list a supernet such as - `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best - effort rather than a guarantee — it is a hand-maintained list and the - caveat below the table applies: +- **It cannot open link-local, the unspecified addresses, or a cloud + metadata endpoint at a non-public address that discloses credentials + or user data.** A metadata address is on the list below when it is not + a public address and both of these hold: the provider fixes it, so it + cannot collide with anything you run; and reaching it hands out + credentials, user data or bootstrap material. Those stay blocked no + matter what you list, including when you list them outright or list a + supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. + Treat this as best effort rather than a guarantee — it is a + hand-maintained list and the caveat below the table applies: | Blocked unconditionally | What it is | | ----------------------- | ---------- | @@ -233,14 +234,25 @@ Two things this setting cannot do: | `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 | | `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT | | `192.0.0.192/32` | Oracle Cloud Classic metadata | + | `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux | + | `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux | | `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address | | `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix | The IPv4-mapped form `::ffff:169.254.169.254` is covered by the - `169.254.0.0/16` entry. Reaching any of these is credential or - user-data theft rather than delivery to an internal service. Every - entry outside the two link-local blocks is a single address, so - blocking it costs you nothing else on the network around it. + `169.254.0.0/16` entry. Reaching any of these but the two unspecified + addresses is credential or user-data theft rather than delivery to an + internal service. Every entry outside the two link-local blocks is a + single address, so blocking it costs you nothing else on the network + around it. + + The unspecified addresses `0.0.0.0` and `::` hand out nothing + themselves, but no host can have either, and on Linux a connection to + one reaches this host's own loopback. They are listed so that an + allowlist reaches loopback only through an entry that covers a loopback + address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one + that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not + open loopback. The six ULA entries, all inside `fd00::/8`, are why this matters in practice: `fd00::/8` is an ordinary block to allowlist for your own @@ -3093,7 +3105,8 @@ check, see [The login endpoint](#the-login-endpoint). route through a single decision function, so they cannot disagree about a destination. An operator can permit specific blocks with [`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the - guard cannot be switched off, and link-local plus a + guard cannot be switched off, and link-local, the unspecified + addresses `0.0.0.0` and `::`, and a [pinned set](#allowing-egress-to-your-own-network) of known cloud metadata endpoints — several of which are ULAs outside link-local — stay blocked whatever is listed, though listing `0.0.0.0/0` or diff --git a/internal/config/config.go b/internal/config/config.go index 2aab026..5a613ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -196,10 +196,11 @@ type Config struct { // otherwise refuse. The guard itself is always on: there is no // setting that disables SSRF protection, and delivery's // alwaysBlockedNetworks stays blocked no matter what is listed - // here. That set is link-local plus the cloud metadata - // endpoints outside it that disclose credentials or user data - // at a provider-fixed, non-public address; it is not - // exhaustive of every cloud's metadata address. See + // here. That set is link-local, the unspecified addresses + // 0.0.0.0 and ::, and the cloud metadata endpoints outside + // link-local that disclose credentials or user data at a + // provider-fixed, non-public address; it is not exhaustive of + // every cloud's metadata address. See // alwaysBlockedNetworks for the authoritative list and the // criterion it is built from. AllowedEgressCIDRs []netip.Prefix diff --git a/internal/delivery/ssrf.go b/internal/delivery/ssrf.go index 9f5533e..164ae01 100644 --- a/internal/delivery/ssrf.go +++ b/internal/delivery/ssrf.go @@ -37,8 +37,8 @@ var ( "blocked cloud metadata address", ) errBlockedMetadata = errors.New( - "blocked link-local or cloud instance metadata " + - "address: ALLOWED_EGRESS_CIDRS cannot open it", + "blocked link-local, cloud instance metadata or " + + "unspecified address: ALLOWED_EGRESS_CIDRS cannot open it", ) errInvalidScheme = errors.New( "only http and https are allowed", @@ -72,14 +72,16 @@ var blockedNetworks []*net.IPNet 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. +// open: the link-local blocks, the cloud instance metadata +// endpoints that live outside them, and the unspecified +// addresses. Reaching a metadata endpoint 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: +// hold, and every entry below but the unspecified addresses +// satisfies both: // // 1. It is a fixed address assigned by the provider, or a // range reserved by IANA — never one the operator chose. @@ -112,6 +114,15 @@ var blockedPublicNetworks []*net.IPNet // This is a criterion, not an enumeration of every metadata // address in existence. // +// The unspecified addresses 0.0.0.0 and :: fail (2) and are here +// anyway. No host can have either, and on Linux a connection to +// one reaches this host's own loopback. Listing them means an +// allowlist reaches loopback only through an entry that covers a +// loopback address (127.0.0.0/8, ::1/128, 0.0.0.0/0), never +// through one that covers only 0.0.0.0 or :: (0.0.0.0/8, for +// example). Nothing else lives at either address, so refusing +// them costs nothing. +// // 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 @@ -131,23 +142,46 @@ var alwaysBlockedNetworks []*net.IPNet //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup func init() { blockedNetworks = mustParseCIDRs([]string{ + // IPv4 loopback. "127.0.0.0/8", + // RFC 1918 private network. "10.0.0.0/8", + // RFC 1918 private network. "172.16.0.0/12", + // RFC 1918 private network. "192.168.0.0/16", + // IPv4 link-local. "169.254.0.0/16", + // "This network", holding the IPv4 unspecified address 0.0.0.0. "0.0.0.0/8", + // Carrier-grade NAT shared address space. "100.64.0.0/10", + // IETF protocol assignments. "192.0.0.0/24", + // IPv4 documentation (TEST-NET-1). "192.0.2.0/24", + // Benchmarking. "198.18.0.0/15", + // IPv4 documentation (TEST-NET-2). "198.51.100.0/24", + // IPv4 documentation (TEST-NET-3). "203.0.113.0/24", + // IPv4 multicast. "224.0.0.0/4", + // Reserved, including the broadcast address. "240.0.0.0/4", + // IPv6 loopback. "::1/128", + // IPv6 unspecified address. + "::/128", + // IPv6 unique local addresses. "fc00::/7", + // IPv6 link-local. "fe80::/10", + // IPv6 multicast. + "ff00::/8", + // IPv6 documentation. + "2001:db8::/32", }) blockedPublicNetworks = mustParseCIDRs([]string{ @@ -207,6 +241,14 @@ func init() { // allowlist from opening it. "192.0.0.192/32", + // The unspecified addresses, each of which reaches this + // host's loopback on Linux. + // + // IPv4 unspecified address, inside the blocked 0.0.0.0/8. + "0.0.0.0/32", + // IPv6 unspecified address. + "::/128", + // 169.254.169.254 as an IPv4-compatible IPv6 address. "::a9fe:a9fe/128", // 169.254.169.254 behind the NAT64 well-known prefix. @@ -343,8 +385,9 @@ func (g *Guard) allows(ip net.IP) bool { // 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. +// consulted, so no configured CIDR reaches link-local, a +// cloud metadata endpoint at a non-public address, or an +// unspecified address. // 2. The allowlist is consulted next, so a listed private // network, or a listed public address on the default // blocklist, becomes reachable. diff --git a/internal/delivery/ssrf_allowlist_test.go b/internal/delivery/ssrf_allowlist_test.go index 1b6d195..1a200b7 100644 --- a/internal/delivery/ssrf_allowlist_test.go +++ b/internal/delivery/ssrf_allowlist_test.go @@ -168,12 +168,13 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) { // TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing // case: cloud instance metadata endpoints are credential theft -// rather than delivery to an internal service, so no allowlist -// reaches one. Every guard below names a CIDR that covers its -// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and -// CGNAT blocks an operator would really list — and the address -// must stay refused anyway, on both the validation and the -// delivery path. +// rather than delivery to an internal service, and the +// unspecified addresses 0.0.0.0 and :: reach this host's loopback +// on Linux, so no allowlist reaches any of them. Every guard +// below names a CIDR that covers its target — including +// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an +// operator would really list — and the address must stay +// refused anyway, on both the validation and the delivery path. func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) { t.Parallel() @@ -219,15 +220,17 @@ type metadataAlwaysRefusedCase struct { } // metadataAlwaysRefusedCases enumerates every unconditionally -// blocked address together with an allowlist entry that would -// otherwise reach it. Split by family of address only to stay -// under the function-length limit. +// blocked address (link-local, the cloud metadata endpoints and +// the unspecified addresses) together with an allowlist entry +// that would otherwise reach it. Split by family of address only +// to stay under the function-length limit. func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase { cases := linkLocalRefusedCases() cases = append(cases, ulaMetadataRefusedCases()...) cases = append(cases, ipv4MetadataRefusedCases()...) + cases = append(cases, encodedMetadataRefusedCases()...) - return append(cases, encodedMetadataRefusedCases()...) + return append(cases, unspecifiedRefusedCases()...) } // linkLocalRefusedCases covers the link-local blocks, including @@ -367,6 +370,23 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase { } } +// unspecifiedRefusedCases covers the unspecified addresses, each +// of which reaches this host's loopback on Linux. +func unspecifiedRefusedCases() []metadataAlwaysRefusedCase { + return []metadataAlwaysRefusedCase{ + { + name: "IPv4 unspecified address under 0.0.0.0/0", + allow: allowAllIPv4, + target: "http://0.0.0.0:8080/hook", + }, + { + name: "IPv6 unspecified address under ::/0", + allow: allowAllIPv6, + target: "http://[::]:8080/hook", + }, + } +} + // TestGuardAllowlist_PublicUnaffected asserts the allowlist does // not narrow anything: public addresses were reachable before it // existed and stay reachable, whether or not a list is set. @@ -524,6 +544,10 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) { // Oracle Cloud Classic metadata, inside the blocked // 192.0.0.0/24. "192.0.0.192/32", + // The IPv4 and IPv6 unspecified addresses, each of + // which reaches this host's loopback on Linux. + "0.0.0.0/32", + "::/128", // 169.254.169.254 as an IPv4-compatible IPv6 address. "::a9fe:a9fe/128", // 169.254.169.254 behind the NAT64 well-known prefix. @@ -556,7 +580,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) { {cidr: "172.16.0.0/12", reopenable: true}, {cidr: "192.168.0.0/16", reopenable: true}, {cidr: linkLocalIPv4, reopenable: false}, - {cidr: "0.0.0.0/8", reopenable: true}, + // Its first address, 0.0.0.0, is in the unconditional set. + {cidr: "0.0.0.0/8", reopenable: false}, {cidr: "100.64.0.0/10", reopenable: true}, {cidr: "192.0.0.0/24", reopenable: true}, {cidr: "192.0.2.0/24", reopenable: true}, @@ -566,8 +591,11 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) { {cidr: "224.0.0.0/4", reopenable: true}, {cidr: "240.0.0.0/4", reopenable: true}, {cidr: "::1/128", reopenable: true}, + {cidr: "::/128", reopenable: false}, {cidr: "fc00::/7", reopenable: true}, {cidr: "fe80::/10", reopenable: false}, + {cidr: "ff00::/8", reopenable: true}, + {cidr: "2001:db8::/32", reopenable: true}, {cidr: "168.63.129.16/32", public: true, reopenable: true}, } diff --git a/internal/delivery/ssrf_test.go b/internal/delivery/ssrf_test.go index 4e79988..f2816bd 100644 --- a/internal/delivery/ssrf_test.go +++ b/internal/delivery/ssrf_test.go @@ -101,6 +101,42 @@ func TestValidateTargetURL_Blocked(t *testing.T) { } } +// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation +// covers the unspecified addresses and the IPv6 multicast and +// documentation ranges: with no allowlist set, each is refused +// both when a target is created and when a delivery dials it. +func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation( + t *testing.T, +) { + t.Parallel() + + guard := delivery.NewTestGuard() + + targets := []string{ + // The unspecified addresses. On Linux a connection to + // either reaches this host's loopback. + "http://0.0.0.0:8080/hook", + "http://[::]:8080/hook", + // IPv6 multicast, all nodes. + "http://[ff02::1]/hook", + // IPv6 documentation. + "http://[2001:db8::1]/hook", + } + + for _, target := range targets { + t.Run(target, func(t *testing.T) { + t.Parallel() + + require.Error(t, + guard.ValidateTargetURL(context.Background(), target), + "%s must be refused at target creation", target, + ) + + assertDialRefused(t, guard, target) + }) + } +} + func TestValidateTargetURL_Allowed(t *testing.T) { t.Parallel() diff --git a/internal/handlers/source_management.go b/internal/handlers/source_management.go index 330e787..c059b9b 100644 --- a/internal/handlers/source_management.go +++ b/internal/handlers/source_management.go @@ -1569,10 +1569,11 @@ func (h *Handlers) validateTargetURL( msg := "Invalid target URL: " + err.Error() // Only a private or reserved address's refusal says how - // to allow it. Metadata refusals never do: link-local and - // the other unconditional metadata addresses cannot be - // opened, and the default blocklist's public addresses, - // which listing does open, hand out credentials. + // to allow it. Other refusals never do: link-local, the + // unspecified addresses and the other unconditional + // metadata addresses cannot be opened, and the default + // blocklist's public addresses, which listing does open, + // hand out credentials. if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) { msg += ". Private and reserved addresses are refused " + "by default; the server's ALLOWED_EGRESS_CIDRS " +