registry/internal/api/handlers/v0/auth/http_internal_test.go
Radoslav Dimitrov f5f40bd980
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>
2026-05-04 09:41:23 -04:00

80 lines
2.4 KiB
Go

package auth
import (
"net"
"testing"
)
func TestIsBlockedIP(t *testing.T) {
tests := []struct {
ip string
blocked bool
}{
// Blocked — loopback
{"127.0.0.1", true},
{"::1", true},
// Blocked — RFC1918 / ULA (IsPrivate)
{"10.0.0.1", true},
{"172.16.0.1", true},
{"192.168.1.1", true},
{"fc00::1", true},
// Blocked — link-local (includes cloud metadata 169.254.169.254)
{"169.254.169.254", true},
{"fe80::1", true},
// Blocked — unspecified
{"0.0.0.0", true},
{"::", true},
// Blocked — admin-scoped and broader multicast
{"239.0.0.1", true},
{"ff00::1", true},
// 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},
}
for _, tc := range tests {
t.Run(tc.ip, func(t *testing.T) {
ip := net.ParseIP(tc.ip)
if ip == nil {
t.Fatalf("ParseIP(%q) returned nil", tc.ip)
}
if got := isBlockedIP(ip); got != tc.blocked {
t.Errorf("isBlockedIP(%q) = %v, want %v", tc.ip, got, tc.blocked)
}
})
}
}