mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-10-13 08:50:22 +08:00
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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user