Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions internal/registries/ssrf.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,66 @@ func isBlockedIP(ip net.IP) bool {
return true
}
}
return ip.IsLoopback() ||
if ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
ip.IsUnspecified() {
return true
}
// IPv6 transition addresses (6to4, NAT64, Teredo, IPv4-compatible) embed an
// IPv4 address none of the checks above look at. Unwrap and re-check it so
// [2002:a9fe:a9fe::1] is treated as 169.254.169.254.
for _, inner := range embeddedIPv4(ip) {
if isBlockedIP(inner) {
return true
}
}
return false
}

// embeddedIPv4 returns the IPv4 addresses carried inside an IPv6 transition
// address, or nil when it carries none. Teredo yields two: the relay server and
// the (obfuscated) client.
func embeddedIPv4(ip net.IP) []net.IP {
v6 := ip.To16()
if v6 == nil || ip.To4() != nil {
return nil
}
switch {
// 6to4 — RFC 3056, 2002::/16, IPv4 in bytes 2-6.
case v6[0] == 0x20 && v6[1] == 0x02:
return []net.IP{net.IPv4(v6[2], v6[3], v6[4], v6[5])}
// NAT64 well-known prefix — RFC 6052, 64:ff9b::/96, IPv4 in the low 32 bits.
case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && allZeros(v6[4:12]):
return []net.IP{net.IPv4(v6[12], v6[13], v6[14], v6[15])}
// NAT64 local-use prefix — RFC 8215, 64:ff9b:1::/48. Embedded IPv4 position
// depends on the operator's prefix length, so block the whole range.
case v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b && v6[4] == 0x00 && v6[5] == 0x01:
return []net.IP{net.IPv4zero}
// Teredo — RFC 4380, 2001::/32. Server IPv4 in bytes 4-8, client IPv4 in
// bytes 12-16 obfuscated by XOR with 0xff.
case v6[0] == 0x20 && v6[1] == 0x01 && v6[2] == 0x00 && v6[3] == 0x00:
return []net.IP{
net.IPv4(v6[4], v6[5], v6[6], v6[7]),
net.IPv4(v6[12]^0xff, v6[13]^0xff, v6[14]^0xff, v6[15]^0xff),
}
// IPv4-compatible — deprecated ::a.b.c.d, not unwrapped by net.IP.To4.
case allZeros(v6[0:12]):
return []net.IP{net.IPv4(v6[12], v6[13], v6[14], v6[15])}
}
return nil
}

func allZeros(b []byte) bool {
for _, x := range b {
if x != 0 {
return false
}
}
return true
}

// hostLiteralBlocked returns a non-nil error if host is a LITERAL IP in a blocked
Expand Down
9 changes: 9 additions & 0 deletions internal/registries/ssrf_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ func TestIsBlockedIP(t *testing.T) {
"::", // unspecified v6
"224.0.0.1", // multicast
"ff02::1", // link-local multicast v6
// IPv6 transition addresses embedding a blocked IPv4.
"2002:a9fe:a9fe::1", // 6to4 -> 169.254.169.254
"2002:7f00:1::1", // 6to4 -> 127.0.0.1
"64:ff9b::a9fe:a9fe", // NAT64 well-known prefix -> 169.254.169.254
"64:ff9b::7f00:1", // NAT64 well-known prefix -> 127.0.0.1
"64:ff9b:1::7f00:1", // NAT64 local-use prefix
"2001:0:0:0:0:0:80ff:fffe", // Teredo -> client 127.0.0.1
"::7f00:1", // deprecated IPv4-compatible -> 127.0.0.1
}
for _, s := range blocked {
ip := net.ParseIP(s)
Expand All @@ -72,6 +80,7 @@ func TestIsBlockedIP(t *testing.T) {
"100.63.255.255", // just below CGNAT 100.64/10
"100.128.0.1", // just above CGNAT 100.64/10
"2606:4700:4700::1111", // public v6 (Cloudflare)
"64:ff9b::808:808", // NAT64 wrapping a public IPv4 (8.8.8.8) stays reachable
}
for _, s := range allowed {
ip := net.ParseIP(s)
Expand Down
Loading