diff --git a/README.md b/README.md index 48a1dd0..2cca760 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,11 @@ private and reserved ranges — RFC 1918, loopback, CGNAT, link-local and the rest — are refused, which stops a target from being used to make webhooker probe the network it sits in. +Besides the private and reserved ranges, the default blocklist refuses +public cloud metadata addresses: currently only `168.63.129.16`, Azure's +WireServer, which serves an Azure VM its credentials. Because it is a +public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it. + That default is also inconvenient for the thing webhooker is mostly for: taking a public webhook and forwarding it to something on your own network. A container on the same Docker network, a box on `10.x`, a @@ -242,7 +247,8 @@ Two things this setting cannot do: encodings, which the default blocklist does not match. A publicly routable metadata address is not listed here, because nothing on this list can be reopened and blocking one that way would leave you no - escape hatch at all. + escape hatch at all; Azure's `168.63.129.16` is refused by the default + blocklist instead, as described above. This list is not exhaustive of every cloud's metadata address — if yours is not here, do not allowlist the block that contains it. diff --git a/internal/delivery/ssrf.go b/internal/delivery/ssrf.go index a718e6a..38f3fe3 100644 --- a/internal/delivery/ssrf.go +++ b/internal/delivery/ssrf.go @@ -26,7 +26,7 @@ var ( "hostname resolved to no IP addresses", ) errBlockedIP = errors.New( - "blocked private/reserved IP range", + "blocked private, reserved or cloud metadata address", ) errBlockedMetadata = errors.New( "blocked link-local or cloud instance metadata " + @@ -37,9 +37,10 @@ var ( ) ) -// 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 +// blockedNetworks is 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. // //nolint:gochecknoglobals // package-level network list is appropriate here @@ -122,6 +123,8 @@ func init() { "::1/128", "fc00::/7", "fe80::/10", + // 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 @@ -216,8 +219,8 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool { } // isBlockedIP checks whether an IP address falls within -// any blocked private/reserved network range, before any -// operator allowlist is considered. +// the default blocklist, before any operator allowlist is +// considered. func isBlockedIP(ip net.IP) bool { return matchesAny(blockedNetworks, ip) } diff --git a/internal/delivery/ssrf_allowlist_test.go b/internal/delivery/ssrf_allowlist_test.go index 74f0f35..314865c 100644 --- a/internal/delivery/ssrf_allowlist_test.go +++ b/internal/delivery/ssrf_allowlist_test.go @@ -390,6 +390,41 @@ func TestGuardAllowlist_PublicUnaffected(t *testing.T) { } } +// TestGuardAllowlist_AzureWireServerReopenable covers Azure's +// WireServer, a public address that serves VM credentials. The +// default guard refuses it, but because it is public it sits in +// the default blocklist rather than the unconditional set, so an +// operator who lists it can reach it. +func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) { + t.Parallel() + + const wireServerIP = "168.63.129.16" + + target := "http://" + wireServerIP + "/?comp=versions" + + defaultGuard := delivery.NewTestGuard() + + err := defaultGuard.ValidateTargetURL(context.Background(), target) + require.Error(t, err, + "WireServer must be refused with no allowlist set", + ) + assert.NotContains(t, err.Error(), metadataRefusalClause, + "WireServer must be refused by the default blocklist, "+ + "which an allowlist can override", + ) + + assertDialRefused(t, defaultGuard, target) + + listed := delivery.NewTestGuard( + netip.MustParsePrefix(wireServerIP + "/32"), + ) + + assert.NoError(t, + listed.ValidateTargetURL(context.Background(), target), + "an operator who lists WireServer must be able to reach it", + ) +} + // TestGuardCheckIP_BothPathsShareOneDecision asserts that the // validator and the dialer are not two policies that happen to // agree: both are defined in terms of checkIP, so the exported