diff --git a/docreader/utils/ssrf.py b/docreader/utils/ssrf.py index be33e0040..c93505da4 100644 --- a/docreader/utils/ssrf.py +++ b/docreader/utils/ssrf.py @@ -176,6 +176,23 @@ def _is_restricted_ip(ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]) - reason = _is_restricted_ip(embedded_ip) if reason: return f"Teredo {label} embeds {reason}" + # NAT64's well-known prefix (64:ff9b::/96, RFC 6052) and the deprecated + # IPv4-compatible form (::a.b.c.d) also carry a plain IPv4 address, but + # ipaddress exposes no accessor for either, so is_private and + # .sixtofour never see the payload. Without these two branches + # ::169.254.169.254 reads as an ordinary public IPv6 address. + # Mirrors internal/ipclass on the Go side. + packed = ip.packed + if packed[0:4] == b"\x00\x64\xff\x9b" and packed[4:12] == bytes(8): + reason = _is_restricted_ip(ipaddress.IPv4Address(packed[12:16])) + if reason: + return f"NAT64-embedded {reason}" + if packed[0:12] == bytes(12): + # :: and ::1 already returned above; ::ffff:a.b.c.d is handled by + # the ipv4_mapped branch. + reason = _is_restricted_ip(ipaddress.IPv4Address(packed[12:16])) + if reason: + return f"IPv4-compatible {reason}" # Site-local (fec0::/10) if (ip.packed[0] == 0xFE) and (ip.packed[1] & 0xC0) == 0xC0: return "site-local IPv6 address" diff --git a/internal/im/yunzhijia/url.go b/internal/im/yunzhijia/url.go index de1a75cc3..c4b96f287 100644 --- a/internal/im/yunzhijia/url.go +++ b/internal/im/yunzhijia/url.go @@ -6,6 +6,8 @@ import ( "net" "net/url" "strings" + + "github.com/Tencent/WeKnora/internal/ipclass" ) func deriveWebSocketURL(sendMsgURL, allowedHostSuffix string) (string, error) { @@ -91,7 +93,11 @@ func safeDialContext(ctx context.Context, network, address string) (net.Conn, er return nil, fmt.Errorf("dial endpoint host %q: %w", host, lastErr) } +// isPublicIP reports whether a resolved endpoint address may be dialled. The +// webhook endpoint is tenant-supplied, so this is an SSRF check and shares +// internal/ipclass with the other outbound guards rather than re-deriving +// "private" from net.IP's predicates, which miss carrier-grade NAT, IPv6 +// site-local, and the encodings that embed an IPv4 link-local address. func isPublicIP(ip net.IP) bool { - return ip != nil && !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast() && - !ip.IsLinkLocalMulticast() && !ip.IsUnspecified() && !ip.IsMulticast() + return ipclass.IsPublic(ip) } diff --git a/internal/ipclass/ipclass.go b/internal/ipclass/ipclass.go new file mode 100644 index 000000000..252f37454 --- /dev/null +++ b/internal/ipclass/ipclass.go @@ -0,0 +1,215 @@ +// Package ipclass categorises IP addresses for outbound-request policy. +// +// The classification lives here, separate from the policy, because WeKnora +// guards two kinds of outbound target whose threat models differ: +// +// - internal/utils guards URLs an end user submits, so it refuses every +// class except Public. +// - internal/sandbox guards cluster endpoints a workspace admin configures, +// so it has to tell "never routable" apart from "private, which this +// workspace may have opted into". +// +// Sharing the classification while keeping the policy at each call site is +// what stops the two from drifting. The drift is not hypothetical: net.IP's +// IsPrivate covers fc00::/7 but not fec0::/10, and neither it nor +// IsLinkLocalUnicast looks inside a 6to4, Teredo, NAT64 or IPv4-compatible +// address, so a hand-rolled "is this private" check silently admits several +// spellings of 169.254.169.254. +package ipclass + +import ( + "fmt" + "net" +) + +// Class is the policy-relevant category of an address. This package decides +// which class an address is; callers decide which classes they refuse. +type Class int + +const ( + // Public is globally routable with no policy objection. + Public Class = iota + + // Invalid is a nil or malformed address. It is its own class rather than + // Public so that a caller which forgets to check fails closed. + Invalid + + // Unspecified is 0.0.0.0 or ::. + Unspecified + + // Loopback is 127.0.0.0/8 or ::1. + Loopback + + // Private is RFC1918 or an IPv6 unique local address (fc00::/7). + Private + + // CGNAT is 100.64.0.0/10 (RFC 6598). IsPrivate does not cover it, but it + // is just as unsuitable as a public endpoint. + CGNAT + + // LinkLocal is 169.254.0.0/16 or fe80::/10, plus link-local multicast. + // This is the class that carries cloud metadata services, and the one + // callers should refuse regardless of any private-network opt-in. + LinkLocal + + // Multicast is any other multicast address, interface-local included. + Multicast + + // SiteLocalIPv6 is the deprecated fec0::/10. Distinct from Private + // because IsPrivate does not cover it. + SiteLocalIPv6 + + // Reserved is a range that cannot reach a real service: 0.0.0.0/8, + // 240.0.0.0/4 (broadcast included), and the IETF assignment and + // benchmarking ranges. + Reserved + + // Documentation is TEST-NET-1/2/3. Reserved for documentation and never + // routed, so refusing it buys no security. It is a separate class so a + // caller can accept it as a DNS-free stand-in for a public address. + Documentation + + // Translated is an IPv6 address carrying a restricted IPv4 address: 6to4, + // Teredo, NAT64's well-known prefix, or the deprecated IPv4-compatible + // form. These are how a target reaches link-local while the outer address + // still looks like ordinary public IPv6. + Translated +) + +// ipv4Range pairs a CIDR with the class addresses inside it belong to. +type ipv4Range struct { + net *net.IPNet + class Class +} + +// restrictedIPv4Ranges covers the IPv4 blocks that net.IP's own predicates +// miss. The entries are disjoint, so the iteration order does not affect the +// verdict. +// +// Two ranges are deliberately absent because a wider entry already covers +// them: 255.255.255.255 sits in 240.0.0.0/4, and Docker's default bridge +// networks (172.17.0.0/16 and neighbours) sit in 172.16.0.0/12, which +// IsPrivate classifies as Private before this table is consulted. +var restrictedIPv4Ranges = []ipv4Range{ + {mustCIDR("100.64.0.0/10"), CGNAT}, // RFC 6598 carrier-grade NAT + {mustCIDR("0.0.0.0/8"), Reserved}, // RFC 1122 "this" network + {mustCIDR("240.0.0.0/4"), Reserved}, // RFC 1112 reserved, incl. broadcast + {mustCIDR("198.18.0.0/15"), Reserved}, // RFC 2544 benchmarking + {mustCIDR("192.0.0.0/24"), Reserved}, // RFC 6890 IETF assignments + {mustCIDR("192.0.2.0/24"), Documentation}, // TEST-NET-1 + {mustCIDR("198.51.100.0/24"), Documentation}, // TEST-NET-2 + {mustCIDR("203.0.113.0/24"), Documentation}, // TEST-NET-3 +} + +func mustCIDR(s string) *net.IPNet { + _, ipNet, err := net.ParseCIDR(s) + if err != nil { + panic(fmt.Sprintf("ipclass: invalid CIDR %s: %v", s, err)) + } + return ipNet +} + +// Classify categorises ip and returns a human-readable reason naming the +// class. The reason is empty for Public. +func Classify(ip net.IP) (Class, string) { + // A net.IP is only meaningful at 4 or 16 bytes. Anything else — nil, or a + // slice built by hand — must not fall through to Public, because callers + // read Public as permission to dial. + if len(ip) != net.IPv4len && len(ip) != net.IPv6len { + return Invalid, "invalid address" + } + + // net.IP's predicates run first because they normalise IPv4-in-IPv6 + // through To4(); that is what catches ::ffff:169.254.169.254 here as + // link-local instead of leaving it to the IPv6 cases below. + switch { + case ip.IsPrivate(): + return Private, "private IP address" + case ip.IsLoopback(): + return Loopback, "loopback address" + case ip.IsLinkLocalUnicast(), ip.IsLinkLocalMulticast(): + return LinkLocal, "link-local address" + case ip.IsMulticast(): + return Multicast, "multicast address" + case ip.IsUnspecified(): + return Unspecified, "unspecified address" + } + + if ip4 := ip.To4(); ip4 != nil { + for _, r := range restrictedIPv4Ranges { + if r.net.Contains(ip4) { + return r.class, fmt.Sprintf("restricted range %s", r.net.String()) + } + } + return Public, "" + } + + if len(ip) == net.IPv6len { + return classifyIPv6(ip) + } + return Public, "" +} + +// IsPublic reports whether ip carries no policy objection at all. Callers that +// need to distinguish "private, maybe allowed" from "never routable" should use +// Classify instead. +func IsPublic(ip net.IP) bool { + class, _ := Classify(ip) + return class == Public +} + +func classifyIPv6(ip net.IP) (Class, string) { + // fec0::/10, deprecated site-local. IsPrivate stops at fc00::/7. + if ip[0] == 0xfe && ip[1]&0xc0 == 0xc0 { + return SiteLocalIPv6, "site-local IPv6 address" + } + // fc00::/7 unique local. IsPrivate already caught this; kept so the + // function is correct when read on its own. + if ip[0]&0xfe == 0xfc { + return Private, "unique local IPv6 address" + } + // Teredo, 2001::/32. Refused outright rather than by embedded address: + // the payload is obfuscated, and the tunnel exists to reach elsewhere. + if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { + return Translated, "Teredo tunneling address" + } + // The remaining encodings carry a plain IPv4 address, so the payload + // decides: 6to4 wrapping a public address is a public address. + if embedded, encoding := embeddedIPv4(ip); embedded != nil { + if class, reason := Classify(embedded); class != Public { + return Translated, fmt.Sprintf("%s %s", encoding, reason) + } + } + return Public, "" +} + +// embeddedIPv4 returns the IPv4 address an IPv6 address carries, for the +// encodings that can reach an IPv4 destination through a relay or translator, +// along with a name for the encoding. +func embeddedIPv4(ip net.IP) (net.IP, string) { + switch { + // 6to4, 2002::/16: bits 16-47 hold the IPv4 address. + case ip[0] == 0x20 && ip[1] == 0x02: + return net.IP(ip[2:6]), "6to4 embedded" + // NAT64 well-known prefix, 64:ff9b::/96 (RFC 6052). Unlike 6to4 this one + // is in live use, so an operator running NAT64 makes every internal IPv4 + // address reachable through it. + case ip[0] == 0x00 && ip[1] == 0x64 && ip[2] == 0xff && ip[3] == 0x9b && + isZeros(ip[4:12]): + return net.IP(ip[12:16]), "NAT64 embedded" + // IPv4-compatible ::a.b.c.d, deprecated by RFC 4291. ::ffff:a.b.c.d never + // reaches here (To4 handles it), and :: and ::1 are already classified. + case isZeros(ip[0:12]): + return net.IP(ip[12:16]), "IPv4-compatible" + } + return nil, "" +} + +func isZeros(b []byte) bool { + for _, v := range b { + if v != 0 { + return false + } + } + return true +} diff --git a/internal/ipclass/ipclass_test.go b/internal/ipclass/ipclass_test.go new file mode 100644 index 000000000..398049a49 --- /dev/null +++ b/internal/ipclass/ipclass_test.go @@ -0,0 +1,129 @@ +package ipclass + +import ( + "net" + "testing" +) + +func TestClassify(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + ip string + class Class + }{ + {"IPv4 public", "8.8.8.8", Public}, + {"IPv6 public", "2001:4860:4860::8888", Public}, + {"IPv4 loopback", "127.0.0.1", Loopback}, + {"IPv6 loopback", "::1", Loopback}, + {"RFC1918 ten", "10.0.0.5", Private}, + {"RFC1918 172", "172.16.3.4", Private}, + {"RFC1918 192", "192.168.1.1", Private}, + {"docker bridge is private", "172.17.0.1", Private}, + {"IPv6 ULA", "fd12:3456:789a::1", Private}, + {"carrier-grade NAT", "100.64.0.1", CGNAT}, + {"cloud metadata", "169.254.169.254", LinkLocal}, + {"IPv6 link-local", "fe80::1", LinkLocal}, + {"unspecified v4", "0.0.0.0", Unspecified}, + {"unspecified v6", "::", Unspecified}, + {"multicast", "239.1.2.3", Multicast}, + {"interface-local multicast", "ff01::1", Multicast}, + {"broadcast", "255.255.255.255", Reserved}, + {"reserved 240/4", "240.0.0.1", Reserved}, + {"benchmarking", "198.18.0.1", Reserved}, + {"IETF assignments", "192.0.0.1", Reserved}, + {"TEST-NET-1", "192.0.2.1", Documentation}, + {"TEST-NET-2", "198.51.100.1", Documentation}, + {"TEST-NET-3", "203.0.113.10", Documentation}, + {"IPv6 documentation prefix is not special", "2001:db8::1", Public}, + + // net.IP's predicates normalise this one through To4(), which is why + // it lands on LinkLocal rather than needing its own encoding case. + {"IPv4-mapped metadata", "::ffff:169.254.169.254", LinkLocal}, + + // The encodings that a guard looking only at the outer address family + // would wave through. Each of these spells 169.254.169.254. + {"6to4 metadata", "2002:a9fe:a9fe::1", Translated}, + {"NAT64 metadata", "64:ff9b::a9fe:a9fe", Translated}, + {"IPv4-compatible metadata", "::169.254.169.254", Translated}, + {"6to4 private payload", "2002:c0a8:0101::1", Translated}, + {"Teredo", "2001:0000:4136:e378:8000:63bf:3fff:fdd2", Translated}, + + // A translation prefix wrapping a public address is public: the + // payload decides, not the wrapper. + {"6to4 public payload", "2002:0808:0808::1", Public}, + {"NAT64 public payload", "64:ff9b::0808:0808", Public}, + + // fec0::/10 is the gap that motivated sharing this classifier: + // net.IP.IsPrivate covers fc00::/7 and stops there. + {"IPv6 site-local", "fec0::1", SiteLocalIPv6}, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ip := net.ParseIP(tc.ip) + if ip == nil { + t.Fatalf("test case has an unparseable address: %s", tc.ip) + } + class, reason := Classify(ip) + if class != tc.class { + t.Fatalf("Classify(%s) = %v (%q), want %v", tc.ip, class, reason, tc.class) + } + if tc.class == Public && reason != "" { + t.Fatalf("Classify(%s) returned reason %q for a public address", tc.ip, reason) + } + if tc.class != Public && reason == "" { + t.Fatalf("Classify(%s) returned class %v with no reason", tc.ip, tc.class) + } + }) + } +} + +// A nil or truncated address must not read as Public: callers use IsPublic to +// decide whether to dial, so an unparseable value has to fail closed. +func TestClassifyRejectsMalformedAddresses(t *testing.T) { + t.Parallel() + + for name, ip := range map[string]net.IP{ + "nil": nil, + "empty": {}, + "truncated": {10, 0, 0}, + } { + class, _ := Classify(ip) + if class != Invalid { + t.Fatalf("Classify(%s) = %v, want Invalid", name, class) + } + if IsPublic(ip) { + t.Fatalf("IsPublic(%s) = true, want false", name) + } + } +} + +func TestIsPublic(t *testing.T) { + t.Parallel() + + // Documentation ranges are a distinct class precisely because callers + // disagree about them; IsPublic is the strict answer. + nonPublic := []string{ + "127.0.0.1", + "10.0.0.1", + "100.64.0.1", + "169.254.169.254", + "fec0::1", + "2002:a9fe:a9fe::1", + "203.0.113.10", + } + for _, raw := range nonPublic { + if IsPublic(net.ParseIP(raw)) { + t.Errorf("IsPublic(%s) = true, want false", raw) + } + } + for _, raw := range []string{"8.8.8.8", "2001:4860:4860::8888"} { + if !IsPublic(net.ParseIP(raw)) { + t.Errorf("IsPublic(%s) = false, want true", raw) + } + } +} diff --git a/internal/sandbox/url_guard.go b/internal/sandbox/url_guard.go index 838fc67c9..286128548 100644 --- a/internal/sandbox/url_guard.go +++ b/internal/sandbox/url_guard.go @@ -16,7 +16,8 @@ // // Self-hosted deployments complicate this, so private endpoints are an // explicit field on each workspace config. Even when enabled, link-local -// ranges — including cloud metadata — stay blocked. +// ranges — including cloud metadata — stay blocked, and that holds for the +// IPv6 encodings that carry an IPv4 link-local address in their payload. package sandbox import ( @@ -26,6 +27,8 @@ import ( "net/url" "strings" "syscall" + + "github.com/Tencent/WeKnora/internal/ipclass" ) // ErrUnsafeOutboundURL is returned for any endpoint that uses an unsupported @@ -141,36 +144,35 @@ func (p OutboundURLPolicy) DialControl(_ string, address string, _ syscall.RawCo } // checkIP applies the policy to a concrete address. +// +// The classification comes from internal/ipclass so this guard and the +// end-user URL guard in internal/utils cannot disagree about what an address +// is; only the verdict per class is local to the sandbox. func (p OutboundURLPolicy) checkIP(ip net.IP) error { if ip == nil { return fmt.Errorf("%w: missing address", ErrUnsafeOutboundURL) } - // Never allowed, even under the private opt-in: link-local carries the - // cloud metadata service, and multicast/unspecified are meaningless here. - if ip.IsUnspecified() || - ip.IsLinkLocalUnicast() || - ip.IsLinkLocalMulticast() || - ip.IsInterfaceLocalMulticast() || - ip.IsMulticast() { - return fmt.Errorf("%w: address %s is never routable to a sandbox", ErrUnsafeOutboundURL, ip) - } - if ip.IsLoopback() || ip.IsPrivate() || isCarrierGradeNAT(ip) { + class, reason := ipclass.Classify(ip) + switch class { + case ipclass.Public, ipclass.Documentation: + // TEST-NET is never routed, so refusing it would protect nothing and + // would cost the tests their DNS-free stand-in for a public address. + case ipclass.Loopback, ipclass.Private, ipclass.CGNAT: + // What the opt-in exists for: Cube listens on 127.0.0.1:33000 by + // default, and a control plane inside RFC1918 is the normal + // self-hosted shape rather than an exception. if !p.AllowPrivate { return fmt.Errorf( "%w: address %s is private; enable private endpoints for this workspace config", ErrUnsafeOutboundURL, ip, ) } + default: + // Refused even under the opt-in. LinkLocal carries the cloud metadata + // service; Translated is how a target reaches it while still looking + // like ordinary public IPv6; the rest cannot reach a sandbox at all. + return fmt.Errorf("%w: address %s is never routable to a sandbox (%s)", + ErrUnsafeOutboundURL, ip, reason) } return nil } - -// isCarrierGradeNAT reports whether ip falls in 100.64.0.0/10, which IsPrivate -// does not cover but which is equally unsuitable as a public endpoint. -func isCarrierGradeNAT(ip net.IP) bool { - v4 := ip.To4() - if v4 == nil { - return false - } - return v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 -} diff --git a/internal/sandbox/url_guard_test.go b/internal/sandbox/url_guard_test.go index 304f6a300..f322e4aed 100644 --- a/internal/sandbox/url_guard_test.go +++ b/internal/sandbox/url_guard_test.go @@ -24,6 +24,26 @@ func TestPolicyRejectsAlwaysForbiddenTargets(t *testing.T) { {"bad scheme gopher", "gopher://evil"}, {"empty", ""}, {"no host", "http://"}, + + // Other spellings of 169.254.169.254. Each wraps the metadata address + // in an IPv6 encoding that a guard inspecting only the outer address + // family reads as ordinary public IPv6. + {"IPv4-mapped metadata", "http://[::ffff:169.254.169.254]"}, + {"6to4 metadata", "http://[2002:a9fe:a9fe::1]"}, + {"NAT64 metadata", "http://[64:ff9b::a9fe:a9fe]"}, + {"IPv4-compatible metadata", "http://[::169.254.169.254]"}, + {"Teredo", "http://[2001:0000:4136:e378:8000:63bf:3fff:fdd2]"}, + + // fec0::/10 is not covered by net.IP.IsPrivate, so it used to pass + // under both policies. + {"IPv6 site-local", "http://[fec0::1]"}, + + // Cannot reach a sandbox, and a config naming one is a typo worth + // reporting at save time. + {"broadcast", "http://255.255.255.255"}, + {"reserved 240/4", "http://240.0.0.1"}, + {"benchmarking range", "http://198.18.0.1"}, + {"IETF assignments", "http://192.0.0.1"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -76,7 +96,11 @@ func TestPolicyAllowsPrivateTargetsWhenOptedIn(t *testing.T) { } func TestPolicyAllowsPublicLiteralAddresses(t *testing.T) { - // Literal addresses keep this hermetic: no DNS required. + // Literal addresses keep this hermetic: no DNS required. They are + // documentation ranges, which this policy accepts on purpose — they are + // never routed, so refusing them would protect nothing while costing every + // test here a DNS lookup. The end-user URL guard in internal/utils makes + // the opposite call for the same class. for _, raw := range []string{ "http://203.0.113.10:8080", "https://203.0.113.10", @@ -110,8 +134,17 @@ func TestPolicyValidateRejectsUnresolvableHost(t *testing.T) { func TestPolicyDialControlMirrorsValidation(t *testing.T) { // The dialer must forbid exactly what validation forbids, otherwise a // saved config would fail mysteriously at first use. - alwaysBlocked := []string{"169.254.169.254:80", "0.0.0.0:80"} - privateOnly := []string{"127.0.0.1:8080", "10.1.2.3:443", "[::1]:8080"} + alwaysBlocked := []string{ + "169.254.169.254:80", + "0.0.0.0:80", + "[::ffff:169.254.169.254]:80", + "[2002:a9fe:a9fe::1]:80", + "[64:ff9b::a9fe:a9fe]:80", + "[::169.254.169.254]:80", + "[fec0::1]:80", + "255.255.255.255:80", + } + privateOnly := []string{"127.0.0.1:8080", "10.1.2.3:443", "[::1]:8080", "100.64.0.1:443"} for _, address := range alwaysBlocked { if err := denyPrivate.DialControl("tcp", address, nil); err == nil { diff --git a/internal/utils/security.go b/internal/utils/security.go index df6427789..51ad061ab 100644 --- a/internal/utils/security.go +++ b/internal/utils/security.go @@ -18,6 +18,8 @@ import ( "unicode/utf8" "golang.org/x/net/http/httpproxy" + + "github.com/Tencent/WeKnora/internal/ipclass" ) // XSS 防护相关正则表达式 @@ -225,35 +227,6 @@ var restrictedHostSuffixes = []string{ ".pod.cluster.local", } -// restrictedIPv4Ranges contains CIDR ranges that should be blocked -// These are additional ranges not covered by Go's IsPrivate(), IsLoopback(), etc. -var restrictedIPv4Ranges = []*net.IPNet{ - // 100.64.0.0/10 - Carrier-grade NAT (RFC 6598) - mustParseCIDR("100.64.0.0/10"), - // 198.18.0.0/15 - Network device benchmark testing (RFC 2544) - mustParseCIDR("198.18.0.0/15"), - // 198.51.100.0/24 - TEST-NET-2 for documentation (RFC 5737) - mustParseCIDR("198.51.100.0/24"), - // 203.0.113.0/24 - TEST-NET-3 for documentation (RFC 5737) - mustParseCIDR("203.0.113.0/24"), - // 192.0.0.0/24 - IETF Protocol Assignments (RFC 6890) - mustParseCIDR("192.0.0.0/24"), - // 192.0.2.0/24 - TEST-NET-1 for documentation (RFC 5737) - mustParseCIDR("192.0.2.0/24"), - // 0.0.0.0/8 - "This" network (RFC 1122) - mustParseCIDR("0.0.0.0/8"), - // 240.0.0.0/4 - Reserved for future use (RFC 1112) - mustParseCIDR("240.0.0.0/4"), - // 255.255.255.255/32 - Limited broadcast - mustParseCIDR("255.255.255.255/32"), - // Docker bridge network (default range) - mustParseCIDR("172.17.0.0/16"), - // Docker user-defined bridge networks (commonly used range) - mustParseCIDR("172.18.0.0/16"), - mustParseCIDR("172.19.0.0/16"), - mustParseCIDR("172.20.0.0/16"), -} - // restrictedPorts contains non-HTTP service ports that user-controlled URLs // must not reach. It is checked both during URL validation and again at dial // time so dynamically discovered URLs cannot bypass the input boundary. @@ -274,76 +247,18 @@ var restrictedPorts = map[string]bool{ "4001": true, // etcd (old) } -// mustParseCIDR parses a CIDR string and panics on error -func mustParseCIDR(s string) *net.IPNet { - _, ipNet, err := net.ParseCIDR(s) - if err != nil { - panic(fmt.Sprintf("invalid CIDR: %s", s)) - } - return ipNet -} - -// isRestrictedIP checks if an IP address falls within any restricted range +// isRestrictedIP checks if an IP address falls within any restricted range. +// +// The URLs guarded here come from end users, who get no opt-in for internal +// targets, so every class except ipclass.Public is restricted. Documentation +// ranges are included in that: they are unroutable, and a user-supplied URL +// has no legitimate reason to name one. func isRestrictedIP(ip net.IP) (bool, string) { - // Check Go's built-in methods first - if ip.IsPrivate() { - return true, "private IP address" + class, reason := ipclass.Classify(ip) + if class == ipclass.Public { + return false, "" } - if ip.IsLoopback() { - return true, "loopback address" - } - if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() { - return true, "link-local address" - } - if ip.IsMulticast() { - return true, "multicast address" - } - if ip.IsUnspecified() { - return true, "unspecified address" - } - - // Check IPv4-specific restricted ranges - if ip4 := ip.To4(); ip4 != nil { - for _, cidr := range restrictedIPv4Ranges { - if cidr.Contains(ip4) { - return true, fmt.Sprintf("restricted range %s", cidr.String()) - } - } - } - - // Check IPv6-specific restrictions - if ip.To4() == nil && len(ip) == 16 { - // Site-local (deprecated but still blocked): fec0::/10 - if ip[0] == 0xfe && (ip[1]&0xc0) == 0xc0 { - return true, "site-local IPv6 address" - } - // Unique local address (ULA): fc00::/7 (already covered by IsPrivate for Go 1.17+) - if (ip[0] & 0xfe) == 0xfc { - return true, "unique local IPv6 address" - } - // IPv4-mapped IPv6 addresses: ::ffff:x.x.x.x - if isZeros(ip[0:10]) && ip[10] == 0xff && ip[11] == 0xff { - mappedIP := ip[12:16] - if restricted, reason := isRestrictedIP(net.IP(mappedIP)); restricted { - return true, fmt.Sprintf("IPv4-mapped %s", reason) - } - } - // Teredo tunneling addresses: 2001:0000::/32 - // Embed arbitrary IPv4 in the payload; can reach internal hosts via relay. - if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { - return true, "Teredo tunneling address" - } - // 6to4 addresses: 2002::/16 - // Bits 16-47 carry an IPv4 address; block when embedded IPv4 is restricted. - if ip[0] == 0x20 && ip[1] == 0x02 { - embeddedIP := net.IP(ip[2:6]) - if restricted, reason := isRestrictedIP(embeddedIP); restricted { - return true, fmt.Sprintf("6to4 embedded %s", reason) - } - } - } - - return false, "" + return true, reason } // IsPublicIP returns true if the IP is safe for outbound fetch (not private, loopback, link-local, etc.). @@ -353,16 +268,6 @@ func IsPublicIP(ip net.IP) bool { return !restricted } -// isZeros checks if a byte slice is all zeros -func isZeros(b []byte) bool { - for _, v := range b { - if v != 0 { - return false - } - } - return true -} - // ipLikePatterns contains regex patterns for detecting IP-like hostnames // These catch various IP address obfuscation techniques var ipLikePatterns = []*regexp.Regexp{