375c4941ffa3cda19b37d4fdb691bde901c87d5c
- Author
- Kieran Klukas <kieran@dunkirk.sh>
- Committer
- Kieran Klukas <kieran@dunkirk.sh>
- Date
Message
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