Skip to content

Commit f5f40bd

Browse files
rdimitrovclaude
andauthored
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>
1 parent 78b7bbd commit f5f40bd

2 files changed

Lines changed: 73 additions & 7 deletions

File tree

internal/api/handlers/v0/auth/http.go

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -109,27 +109,65 @@ func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error
109109
return nil, fmt.Errorf("dial %s: all resolved public addresses failed: %w", host, lastErr)
110110
}
111111

112+
// mustCIDR parses a CIDR literal at init; panics on malformed input so a
113+
// typo surfaces at startup rather than letting a nil *IPNet through to the
114+
// blocklist check (which would silently fail-open).
115+
func mustCIDR(s string) *net.IPNet {
116+
_, n, err := net.ParseCIDR(s)
117+
if err != nil {
118+
panic(fmt.Sprintf("auth: invalid CIDR %q: %v", s, err))
119+
}
120+
return n
121+
}
122+
112123
// cgnatRange covers RFC 6598 Carrier-Grade NAT (100.64.0.0/10), which the
113124
// stdlib does not classify via any Is* helper but is reachable on some
114125
// cloud / mobile networks where it shadows internal infrastructure.
115-
var cgnatRange = func() *net.IPNet {
116-
_, n, _ := net.ParseCIDR("100.64.0.0/10")
117-
return n
118-
}()
126+
var cgnatRange = mustCIDR("100.64.0.0/10")
127+
128+
// blockedIPv6Prefixes are IPv6 ranges that either embed an arbitrary IPv4
129+
// address (and therefore tunnel into RFC1918 / cloud-metadata space on
130+
// hosts with the corresponding routing) or are routed into site-local
131+
// internal networks. None of these are caught by Go's per-class Is*
132+
// helpers.
133+
//
134+
// 2002::/16 RFC 3056 6to4 — bits 16-47 are an IPv4 address
135+
// 64:ff9b::/96 RFC 6052 NAT64 well-known — low 32 bits are IPv4
136+
// 64:ff9b:1::/48 RFC 8215 NAT64 local-use — same IPv4-embedding shape
137+
// fec0::/10 RFC 3879 site-local (deprecated, still routed by some
138+
// stacks)
139+
var blockedIPv6Prefixes = []*net.IPNet{
140+
mustCIDR("2002::/16"),
141+
mustCIDR("64:ff9b::/96"),
142+
mustCIDR("64:ff9b:1::/48"),
143+
mustCIDR("fec0::/10"),
144+
}
119145

120146
// isBlockedIP reports whether an IP must not be dialled by the namespace
121147
// verification fetcher. Covers loopback (127/8, ::1), RFC1918 + ULA via
122148
// IsPrivate, link-local (169.254/16, fe80::/10 — includes cloud metadata
123149
// 169.254.169.254), unspecified (0.0.0.0, ::), all multicast (admin-scoped
124-
// 239/8 and ff00::/8 in addition to link-local-multicast), and CGNAT.
150+
// 239/8 and ff00::/8 in addition to link-local-multicast), CGNAT, and
151+
// IPv6 prefix families that tunnel to or embed arbitrary IPv4 addresses
152+
// (see blockedIPv6Prefixes). IPv4-mapped IPv6 (::ffff:0:0/96) is handled
153+
// implicitly: the stdlib Is* helpers honour the To4() fast-path, so e.g.
154+
// ::ffff:10.0.0.1 is correctly classified as IsPrivate.
125155
func isBlockedIP(ip net.IP) bool {
126156
if ip == nil {
127157
return true
128158
}
129-
return ip.IsLoopback() || ip.IsPrivate() ||
159+
if ip.IsLoopback() || ip.IsPrivate() ||
130160
ip.IsLinkLocalUnicast() || ip.IsMulticast() ||
131161
ip.IsUnspecified() ||
132-
cgnatRange.Contains(ip)
162+
cgnatRange.Contains(ip) {
163+
return true
164+
}
165+
for _, p := range blockedIPv6Prefixes {
166+
if p.Contains(ip) {
167+
return true
168+
}
169+
}
170+
return false
133171
}
134172

135173
// NewDefaultHTTPKeyFetcherWithClient creates a new HTTP key fetcher with a custom HTTP client.

internal/api/handlers/v0/auth/http_internal_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,38 @@ func TestIsBlockedIP(t *testing.T) {
3030
// Blocked — Carrier-Grade NAT (RFC 6598)
3131
{"100.64.0.1", true},
3232
{"100.127.255.254", true},
33+
// Blocked — IPv6 6to4 (RFC 3056 2002::/16); bits 16-47 are an
34+
// arbitrary IPv4 address, so 2002:a9fe:a9fe:: tunnels to
35+
// 169.254.169.254 and 2002:0a00:0001:: tunnels to 10.0.0.1.
36+
{"2002:a9fe:a9fe::", true},
37+
{"2002:0a00:0001::", true},
38+
{"2002::1", true},
39+
// Blocked — IPv6 NAT64 well-known prefix (RFC 6052 64:ff9b::/96);
40+
// low 32 bits embed an IPv4 address.
41+
{"64:ff9b::a9fe:a9fe", true},
42+
{"64:ff9b::a00:1", true},
43+
// Blocked — IPv6 NAT64 local-use prefix (RFC 8215 64:ff9b:1::/48).
44+
{"64:ff9b:1::1", true},
45+
{"64:ff9b:1:abcd::", true},
46+
// Blocked — IPv6 deprecated site-local (RFC 3879 fec0::/10);
47+
// still routed into internal networks by some stacks.
48+
{"fec0::1", true},
49+
{"feff::1", true},
50+
// Blocked — IPv4-mapped IPv6 (::ffff:0:0/96) inherits the
51+
// classification of the wrapped IPv4 via To4() fast-path; covered
52+
// here as an explicit regression guard.
53+
{"::ffff:127.0.0.1", true},
54+
{"::ffff:10.0.0.1", true},
55+
{"::ffff:169.254.169.254", true},
3356
// Allowed — public
3457
{"1.1.1.1", false},
3558
{"8.8.8.8", false},
3659
{"2606:4700:4700::1111", false},
60+
{"2001:4860:4860::8888", false},
61+
// Allowed — just outside the new IPv6 blocks
62+
{"2001::1", false}, // outside 2002::/16
63+
{"2003::1", false}, // outside 2002::/16 on the other side
64+
{"64:ff9c::1", false}, // outside 64:ff9b::/96
3765
// Allowed — outside CGNAT range
3866
{"100.63.255.255", false},
3967
{"100.128.0.1", false},

0 commit comments

Comments
 (0)