fix: share IP classification between SSRF and sandbox URL guards

Keep one classifier for address kinds so user-fetch SSRF and sandbox
control-plane checks cannot disagree, and refuse IPv6 encodings that
embed link-local or other never-routable targets.
This commit is contained in:
wizardchen
2026-09-01 15:35:14 +08:00
committed by lyingbug
parent 9ab0602646
commit ff818b681f
7 changed files with 440 additions and 133 deletions
+17
View File
@@ -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"
+8 -2
View File
@@ -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)
}
+215
View File
@@ -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
}
+129
View File
@@ -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)
}
}
}
+23 -21
View File
@@ -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
}
+36 -3
View File
@@ -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 {
+12 -107
View File
@@ -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{