From f755c03110706731eeb058b01defcb0162784ab0 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 10:22:07 +0200 Subject: [PATCH] Default-block Azure WireServer's public address (closes #245) Add 168.63.129.16 (Azure WireServer) to blockedNetworks, the default blocklist, not alwaysBlockedNetworks: it is public unicast, so an operator who lists it in ALLOWED_EGRESS_CIDRS can reach it again. The refusal message, the allowlist startup warning, the README and the comments no longer call every blocked address private/reserved, and no longer claim the allowlist cannot open any metadata endpoint. Sources: - https://learn.microsoft.com/en-us/azure/virtual-network/what-is-ip-address-168-63-129-16 - https://learn.microsoft.com/en-us/azure/virtual-machines/metadata-security-protocol/overview Deviation: 147.75.207.243 (Equinix Metal) is not added; Equinix documents only a hostname, and the service was sunset on 2026-06-30. Model: opus-5-5 --- README.md | 27 +++++++++++------- internal/config/config.go | 21 ++++++++------ internal/config/config_test.go | 13 +++++---- internal/delivery/ssrf.go | 17 +++++++----- internal/delivery/ssrf_allowlist_test.go | 35 ++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 48a1dd0..5729606 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 @@ -195,15 +200,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 that - discloses credentials or user data.** An address is on the list below - when 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, 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: | Blocked unconditionally | What it is | | ----------------------- | ---------- | @@ -242,7 +248,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/config/config.go b/internal/config/config.go index 69c5210..1b9a4e7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -192,9 +192,10 @@ type Config struct { // 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 address; it is not exhaustive of every - // cloud's metadata address. See alwaysBlockedNetworks for the - // authoritative list and the criterion it is built from. + // 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 params *ConfigParams @@ -746,12 +747,14 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) { log.Warn( "ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+ - "otherwise-blocked private/reserved networks. Anyone "+ - "who can create a delivery target can now make this "+ - "process issue requests into them, and read back the "+ - "response. Link-local and the known cloud instance "+ - "metadata endpoints outside it stay blocked "+ - "regardless of what is listed here.", + "otherwise-blocked networks. Anyone who can create a "+ + "delivery target can now make this process issue "+ + "requests into them, and read back the response. Only "+ + "the addresses the README lists as blocked "+ + "unconditionally stay blocked regardless of what is "+ + "listed here; a public cloud metadata address such as "+ + "168.63.129.16 is reachable once it, or a block "+ + "covering it, is listed.", "allowedEgressCIDRs", strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","), ) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 0add1b2..a7cc76e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -834,12 +834,13 @@ func TestEgressAllowlistWarning(t *testing.T) { // to be able to read back which networks are open. assert.Contains(t, logged, "10.0.0.0/8") assert.Contains(t, logged, "127.0.0.0/8") - // What stays shut. Asserted on the clause naming the - // wider set rather than on "Link-local" alone, so the - // string cannot narrow back to link-local only while - // the always-blocked set covers ULA, CGNAT and two - // public metadata addresses as well. - assert.Contains(t, logged, "metadata endpoints outside it") + // What stays shut is the whole unconditional set, not + // link-local alone; a public metadata address is not in + // it, so a listed block covering it opens it. + assert.Contains(t, logged, "blocked unconditionally") + assert.Contains(t, logged, "168.63.129.16 is reachable") + // The listed blocks need not be private or reserved. + assert.NotContains(t, logged, "private/reserved") }) } } diff --git a/internal/delivery/ssrf.go b/internal/delivery/ssrf.go index a718e6a..0fa73b3 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) } @@ -320,7 +323,7 @@ func (g *Guard) allows(ip net.IP) bool { // // 1. alwaysBlockedNetworks is refused before the allowlist is // consulted, so no configured CIDR reaches link-local or a -// cloud instance metadata endpoint. +// cloud metadata endpoint at a non-public address. // 2. The allowlist is consulted next, so a listed private // network becomes reachable. // 3. Everything else keeps the default blocklist's answer. 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