fix(auth/http): extend SSRF blocklist to IPv6 6to4/NAT64/site-local prefixes (#1250)

The HTTP namespace-verification fetcher's `isBlockedIP` composed its
blocklist from Go stdlib's `Is*` helpers plus a manual CGNAT range. That
covers IPv4 cloud metadata, RFC1918, ULA, and link-local — but Go's
classification helpers don't recognise IPv6 prefix families that embed
or tunnel to arbitrary IPv4:

  - 2002::/16       RFC 3056 6to4 — bits 16-47 are an arbitrary IPv4
  - 64:ff9b::/96    RFC 6052 NAT64 well-known — low 32 bits are IPv4
  - 64:ff9b:1::/48  RFC 8215 NAT64 local-use — same IPv4-embedding shape
  - fec0::/10       RFC 3879 site-local (deprecated, still routed by
                    some stacks)

On hosts with 6to4 routing or a NAT64 next-hop (the default on
IPv6-only GKE node pools, AWS IPv6-only EC2, Azure IPv6 VMs with
NAT64, and many corporate DNS64/NAT64 networks), an attacker who
controls DNS for a publisher domain can cause the registry to dial
`169.254.169.254`, `10.0.0.0/8`, etc. via the IPv6 wrapper. Same
SSRF class as GHSA-56c3-vfp2-5qqj on n8n-mcp.

Define the four prefixes via a new `mustCIDR` helper (panic on
malformed CIDR at init so a typo can't fail-open silently) and iterate
them in `isBlockedIP` after the stdlib gates. Refactor the existing
CGNAT IIFE to use the same helper for consistency. IPv4-mapped IPv6
(::ffff:0:0/96) needs no special-casing: Go's `Is*` helpers honour the
`To4()` fast-path, so e.g. `::ffff:10.0.0.1` is already classified as
`IsPrivate`.

Adds 17 test cases to `TestIsBlockedIP` covering each new prefix
family, IPv4-mapped IPv6 sanity checks, and just-outside-the-range
positive controls (`2001::1`, `2003::1`, `64:ff9c::1`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Radoslav Dimitrov 2026-05-04 09:41:23 -04:00 • committed by GitHub
commit f5f40bd980
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 73 additions and 7 deletions

View file

@ -109,27 +109,65 @@ func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error
return nil, fmt.Errorf("dial %s: all resolved public addresses failed: %w", host, lastErr)
}
// mustCIDR parses a CIDR literal at init; panics on malformed input so a
// typo surfaces at startup rather than letting a nil *IPNet through to the
// blocklist check (which would silently fail-open).
func mustCIDR(s string) *net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic(fmt.Sprintf("auth: invalid CIDR %q: %v", s, err))
}
return n
}
// cgnatRange covers RFC 6598 Carrier-Grade NAT (100.64.0.0/10), which the
// stdlib does not classify via any Is* helper but is reachable on some
// cloud / mobile networks where it shadows internal infrastructure.
var cgnatRange = func() *net.IPNet {
_, n, _ := net.ParseCIDR("100.64.0.0/10")
return n
}()
var cgnatRange = mustCIDR("100.64.0.0/10")
// blockedIPv6Prefixes are IPv6 ranges that either embed an arbitrary IPv4
// address (and therefore tunnel into RFC1918 / cloud-metadata space on
// hosts with the corresponding routing) or are routed into site-local
// internal networks. None of these are caught by Go's per-class Is*
// helpers.
//
// 2002::/16 RFC 3056 6to4 — bits 16-47 are an IPv4 address
// 64:ff9b::/96 RFC 6052 NAT64 well-known — low 32 bits are IPv4
// 64:ff9b:1::/48 RFC 8215 NAT64 local-use — same IPv4-embedding shape
// fec0::/10 RFC 3879 site-local (deprecated, still routed by some
// stacks)
var blockedIPv6Prefixes = []*net.IPNet{
mustCIDR("2002::/16"),
mustCIDR("64:ff9b::/96"),
mustCIDR("64:ff9b:1::/48"),
mustCIDR("fec0::/10"),
}
// isBlockedIP reports whether an IP must not be dialled by the namespace
// verification fetcher. Covers loopback (127/8, ::1), RFC1918 + ULA via
// IsPrivate, link-local (169.254/16, fe80::/10 — includes cloud metadata
// 169.254.169.254), unspecified (0.0.0.0, ::), all multicast (admin-scoped
// 239/8 and ff00::/8 in addition to link-local-multicast), and CGNAT.
// 239/8 and ff00::/8 in addition to link-local-multicast), CGNAT, and
// IPv6 prefix families that tunnel to or embed arbitrary IPv4 addresses
// (see blockedIPv6Prefixes). IPv4-mapped IPv6 (::ffff:0:0/96) is handled
// implicitly: the stdlib Is* helpers honour the To4() fast-path, so e.g.
// ::ffff:10.0.0.1 is correctly classified as IsPrivate.
func isBlockedIP(ip net.IP) bool {
if ip == nil {
return true
}
return ip.IsLoopback() || ip.IsPrivate() ||
if ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsMulticast() ||
ip.IsUnspecified() ||
cgnatRange.Contains(ip)
cgnatRange.Contains(ip) {
return true
}
for _, p := range blockedIPv6Prefixes {
if p.Contains(ip) {
return true
}
}
return false
}
// NewDefaultHTTPKeyFetcherWithClient creates a new HTTP key fetcher with a custom HTTP client.

View file

@ -30,10 +30,38 @@ func TestIsBlockedIP(t *testing.T) {
// Blocked — Carrier-Grade NAT (RFC 6598)
{"100.64.0.1", true},
{"100.127.255.254", true},
// Blocked — IPv6 6to4 (RFC 3056 2002::/16); bits 16-47 are an
// arbitrary IPv4 address, so 2002:a9fe:a9fe:: tunnels to
// 169.254.169.254 and 2002:0a00:0001:: tunnels to 10.0.0.1.
{"2002:a9fe:a9fe::", true},
{"2002:0a00:0001::", true},
{"2002::1", true},
// Blocked — IPv6 NAT64 well-known prefix (RFC 6052 64:ff9b::/96);
// low 32 bits embed an IPv4 address.
{"64:ff9b::a9fe:a9fe", true},
{"64:ff9b::a00:1", true},
// Blocked — IPv6 NAT64 local-use prefix (RFC 8215 64:ff9b:1::/48).
{"64:ff9b:1::1", true},
{"64:ff9b:1:abcd::", true},
// Blocked — IPv6 deprecated site-local (RFC 3879 fec0::/10);
// still routed into internal networks by some stacks.
{"fec0::1", true},
{"feff::1", true},
// Blocked — IPv4-mapped IPv6 (::ffff:0:0/96) inherits the
// classification of the wrapped IPv4 via To4() fast-path; covered
// here as an explicit regression guard.
{"::ffff:127.0.0.1", true},
{"::ffff:10.0.0.1", true},
{"::ffff:169.254.169.254", true},
// Allowed — public
{"1.1.1.1", false},
{"8.8.8.8", false},
{"2606:4700:4700::1111", false},
{"2001:4860:4860::8888", false},
// Allowed — just outside the new IPv6 blocks
{"2001::1", false}, // outside 2002::/16
{"2003::1", false}, // outside 2002::/16 on the other side
{"64:ff9c::1", false}, // outside 64:ff9b::/96
// Allowed — outside CGNAT range
{"100.63.255.255", false},
{"100.128.0.1", false},