375c4941ffa3cda19b37d4fdb691bde901c87d5c

Author
Kieran Klukas <kieran@dunkirk.sh>
Committer
Kieran Klukas <kieran@dunkirk.sh>
Date

Message

fix: block outbound requests to IPv6 addresses that embed IPv4

The check that keeps webhooks and Git remotes from reaching internal
hosts reasoned about IPv4 and IPv6 as separate address spaces. Several
IPv6 forms carry an IPv4 address inside them, and those went
uninspected, so an authenticated user could name a loopback, private, or
cloud metadata host in a form the check read as ordinary public IPv6.
The NAT64 encoding of the metadata endpoint, 64:ff9b::a9fe:a9fe, passed.

Four encodings were affected: 6to4, both NAT64 prefixes, and Teredo. The
deprecated IPv4-compatible form and the 6to4 relay anycast range were
also unhandled.

These ranges are now blocked in full rather than decoded and re-checked
against the address inside them. Blocking the range removes the whole
class of bypass instead of depending on implementing each decoding
correctly, and nothing here needs to reach a host through one of these
encodings. One consequence is worth stating: such an encoding of a
genuinely public address is now rejected too.

The ranges are expressed as network prefixes rather than the byte
comparisons used before, which is what allowed the gap in the first
place. Each of those comparisons sat behind a conversion to IPv4, so
none of them ever ran for an address that was not already IPv4. Stating
the ranges as prefixes lets them be read against the RFCs that define
them, and is how the standard library represents the same space.

Replacing the standard library address helpers with a table risks
quietly narrowing the guard, so a test asserts that every range those
helpers covered implicitly is still covered.

Reported-by: tonghuaroot (童话) <tonghuaroot@gmail.com>

Diff

This diff is truncated to protect this page.

  1diff --git a/pkg/ssrf/ssrf.go b/pkg/ssrf/ssrf.go
  2index 475c1cd7216eb7400a01dc39227b8ac818af64cb..cfb90474bf1bca0ea18f3e788b4c642b647b3e53 100644
  3--- a/pkg/ssrf/ssrf.go
  4+++ b/pkg/ssrf/ssrf.go
  5@@ -6,6 +6,7 @@ import (
  6 	"fmt"
  7 	"net"
  8 	"net/http"
  9+	"net/netip"
 10 	"net/url"
 11 	"slices"
 12 	"strings"
 13@@ -73,55 +74,71 @@ func NewSecureClient() *http.Client {
 14 	}
 15 }
 16 
 17-// isPrivateOrInternal checks if an IP address is private, internal, or reserved.
 18-func isPrivateOrInternal(ip net.IP) bool {
 19-	// Normalize IPv6-mapped IPv4 (e.g. ::ffff:127.0.0.1) to IPv4 form
 20-	// so all checks apply consistently.
 21-	if ip4 := ip.To4(); ip4 != nil {
 22-		ip = ip4
 23-	}
 24+// blockedPrefixes lists every network an outbound request must not reach.
 25+//
 26+// Prefixes are used rather than byte comparisons so the ranges stay readable
 27+// and auditable against the RFCs that define them. This is also how the
 28+// standard library models the same address space internally.
 29+//
 30+// The IPv6 transition ranges need explanation. Each embeds an arbitrary IPv4
 31+// address inside an IPv6 one, so an address that looks public to the IPv6
 32+// checks can name a loopback, RFC1918, or cloud metadata host once a relay
 33+// decodes it. They are blocked in full rather than decoded and re-checked,
 34+// because nothing here has a legitimate reason to reach a host through one of
 35+// these encodings. Blocking the range removes the whole class of bypass
 36+// instead of depending on getting each decoding exactly right.
 37+var blockedPrefixes = []netip.Prefix{
 38+	// IPv4.
 39+	netip.MustParsePrefix("0.0.0.0/8"),       // "this network"
 40+	netip.MustParsePrefix("10.0.0.0/8"),      // RFC1918 private
 41+	netip.MustParsePrefix("100.64.0.0/10"),   // RFC6598 shared address space (CGNAT)
 42+	netip.MustParsePrefix("127.0.0.0/8"),     // loopback
 43+	netip.MustParsePrefix("169.254.0.0/16"),  // link-local, includes cloud metadata
 44+	netip.MustParsePrefix("172.16.0.0/12"),   // RFC1918 private
 45+	netip.MustParsePrefix("192.0.0.0/24"),    // IETF protocol assignments
 46+	netip.MustParsePrefix("192.0.2.0/24"),    // TEST-NET-1
 47+	netip.MustParsePrefix("192.88.99.0/24"),  // RFC7526 6to4 relay anycast
 48+	netip.MustParsePrefix("192.168.0.0/16"),  // RFC1918 private
 49+	netip.MustParsePrefix("198.18.0.0/15"),   // RFC2544 benchmarking
 50+	netip.MustParsePrefix("198.51.100.0/24"), // TEST-NET-2
 51+	netip.MustParsePrefix("203.0.113.0/24"),  // TEST-NET-3
 52+	netip.MustParsePrefix("224.0.0.0/4"),     // multicast
 53+	netip.MustParsePrefix("240.0.0.0/4"),     // reserved, includes broadcast
 54+
 55+	// IPv6.
 56+	netip.MustParsePrefix("::1/128"),        // loopback
 57+	netip.MustParsePrefix("64:ff9b::/96"),   // RFC6052 NAT64 well-known prefix
 58+	netip.MustParsePrefix("64:ff9b:1::/48"), // RFC8215 NAT64 local-use prefix
 59+	netip.MustParsePrefix("100::/64"),       // RFC6666 discard-only
 60+	netip.MustParsePrefix("2001::/32"),      // RFC4380 Teredo
 61+	netip.MustParsePrefix("2001:db8::/32"),  // documentation
 62+	netip.MustParsePrefix("2002::/16"),      // RFC3056 6to4
 63+	netip.MustParsePrefix("fc00::/7"),       // unique local
 64+	netip.MustParsePrefix("fe80::/10"),      // link-local
 65+	netip.MustParsePrefix("ff00::/8"),       // multicast
 66+
 67+	// IPv4-compatible IPv6 (::x.x.x.x), deprecated by RFC4291 but still
 68+	// parsed. Unmap leaves this form alone, so the IPv4 prefixes above do not
 69+	// apply to it. Covers the unspecified address too.
 70+	netip.MustParsePrefix("::/96"),
 71+}
 72 
 73-	if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
 74-		ip.IsPrivate() || ip.IsUnspecified() || ip.IsMulticast() {
 75+// isPrivateOrInternal reports whether an IP address is private, internal, or
 76+// otherwise unsafe to send an outbound request to.
 77+func isPrivateOrInternal(ip net.IP) bool {
 78+	addr, ok := netip.AddrFromSlice(ip)
 79+	if !ok {
 80+		// Not an address we can reason about, so refuse rather than allow.
 81 		return true
 82 	}
 83 
 84-	if ip4 := ip.To4(); ip4 != nil {
 85-		// 0.0.0.0/8
 86-		if ip4[0] == 0 {
 87-			return true
 88-		}
 89-		// 100.64.0.0/10 (Shared Address Space / CGNAT)
 90-		if ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 {
 91-			return true
 92-		}
 93-		// 192.0.0.0/24 (IETF Protocol Assignments)
 94-		if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 0 {
 95-			return true
 96-		}
 97-		// 192.0.2.0/24 (TEST-NET-1)
 98-		if ip4[0] == 192 && ip4[1] == 0 && ip4[2] == 2 {
 99-			return true
100-		}
101-		// 198.18.0.0/15 (benchmarking)
102-		if ip4[0] == 198 && (ip4[1] == 18 || ip4[1] == 19) {
103-			return true
104-		}
105diff --git a/pkg/ssrf/ssrf_test.go b/pkg/ssrf/ssrf_test.go
106index 1b14194695a6d694221dd2a15050dd8027349720..87cab19269c017ea2b5e2def5802bee5a85ebfac 100644
107--- a/pkg/ssrf/ssrf_test.go
108+++ b/pkg/ssrf/ssrf_test.go
109@@ -147,6 +147,28 @@ func TestIsPrivateOrInternal(t *testing.T) {
110 		// Reserved
111 		{"0.0.0.0", true},
112 		{"240.0.0.1", true},
113+
114+		// IPv6 transition addresses. Each embeds an IPv4 address inside an
115+		// IPv6 one, so they look public to the IPv6 checks while naming an
116+		// internal host once a relay decodes them. The whole prefix is
117+		// blocked, so the embedded address does not matter: encodings of
118+		// public addresses are rejected too.
119+		{"2002:7f00:0001::", true},                        // 6to4 -> 127.0.0.1
120+		{"2002:a9fe:a9fe::", true},                        // 6to4 -> 169.254.169.254
121+		{"2002:0a00:0001::", true},                        // 6to4 -> 10.0.0.1
122+		{"2002:0808:0808::", true},                        // 6to4 -> 8.8.8.8, still blocked
123+		{"64:ff9b::7f00:1", true},                         // NAT64 well-known -> 127.0.0.1
124+		{"64:ff9b::a9fe:a9fe", true},                      // NAT64 well-known -> 169.254.169.254
125+		{"64:ff9b:1::7f00:1", true},                       // NAT64 local-use -> 127.0.0.1
126+		{"2001:0000:4136:e378:8000:63bf:8001:5601", true}, // Teredo
127+		{"::7f00:1", true},                                // IPv4-compatible -> 127.0.0.1
128+		{"192.88.99.1", true},                             // 6to4 relay anycast
129+
130+		// Public IPv6 must stay reachable: the transition prefixes are narrow
131+		// and must not swallow ordinary global unicast.
132+		{"2606:4700:4700::1111", false},
133+		{"2a00:1450:4001::1", false},
134+		{"2400:cb00::1", false},
135 	}
136 
137 	for _, tt := range tests {
138@@ -162,6 +184,31 @@ func TestIsPrivateOrInternal(t *testing.T) {
139 	}
140 }
141 
142+// TestStdlibCoverageParity guards the switch from the standard library's
143+// Is*() helpers to an explicit prefix table. The helpers covered these ranges
144+// implicitly, so the table has to cover them too or the refactor silently
145+// narrows the guard.
146+func TestStdlibCoverageParity(t *testing.T) {
147+	for _, ip := range []string{
148+		// Loopback.
149+		"127.0.0.1", "127.255.255.254", "::1",
150+		// Private.
151+		"10.255.255.255", "172.31.255.255", "192.168.255.255", "fd00::1", "fdff::1",
152+		// Link-local unicast.
153+		"169.254.1.1", "fe80::1", "febf::1",
154+		// Multicast, including link-local and interface-local.
155+		"224.0.0.1", "239.255.255.255", "ff00::1", "ff01::1", "ff02::1", "ffff::1",
156+		// Unspecified.
157+		"0.0.0.0", "::",
158+	} {
159+		t.Run(ip, func(t *testing.T) {
160+			if !isPrivateOrInternal(net.ParseIP(ip)) {
161+				t.Errorf("isPrivateOrInternal(%s) = false, want true", ip)
162+			}
163+		})
164+	}
165+}
166+
167 func TestValidateURL(t *testing.T) {
168 	tests := []struct {
169 		name    string