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:
parent
78b7bbde07
commit
f5f40bd980
2 changed files with 73 additions and 7 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
Loading…
Reference in a new issue