fix(hostmatcher): block reserved IP ranges from external/private filters (#38039)
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HostMatchList is used to check if a host or IP is in a list.
|
// HostMatchList is used to check if a host or IP is in a list.
|
||||||
@@ -23,10 +24,64 @@ type HostMatchList struct {
|
|||||||
ipNets []*net.IPNet
|
ipNets []*net.IPNet
|
||||||
}
|
}
|
||||||
|
|
||||||
// MatchBuiltinExternal A valid non-private unicast IP, all hosts on public internet are matched
|
// MatchBuiltinExternal A valid global-unicast IP that is neither private (see MatchBuiltinPrivate)
|
||||||
|
// nor a reserved special-purpose range (see reservedIPNets); i.e. a routable host on the public internet.
|
||||||
const MatchBuiltinExternal = "external"
|
const MatchBuiltinExternal = "external"
|
||||||
|
|
||||||
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7). Also called LAN/Intranet.
|
// reservedIPNets are special-purpose ranges that net.IP.IsPrivate omits but that must not be
|
||||||
|
// treated as public/external destinations (CGNAT, cloud metadata, IPv6 transition, etc.). We layer
|
||||||
|
// these on top of net.IP.IsPrivate (RFC 1918 / RFC 4193) so future additions to Go's IsPrivate are
|
||||||
|
// picked up automatically, while still covering the ranges it leaves out; otherwise the default
|
||||||
|
// allow-list would let authenticated users reach cloud metadata, internal, and IPv6 transition
|
||||||
|
// endpoints (SSRF), and a "private" block-list would fail to catch them.
|
||||||
|
var reservedIPNets = sync.OnceValue(func() []*net.IPNet {
|
||||||
|
var nets []*net.IPNet
|
||||||
|
for _, cidr := range []string{
|
||||||
|
// IPv4
|
||||||
|
"100.64.0.0/10", // RFC 6598 Carrier-Grade NAT
|
||||||
|
"168.63.129.16/32", // Azure WireServer metadata endpoint
|
||||||
|
"192.0.0.0/24", // RFC 6890 IETF protocol assignments
|
||||||
|
"192.0.2.0/24", // RFC 5737 TEST-NET-1
|
||||||
|
"192.88.99.0/24", // RFC 7526 6to4 relay anycast (deprecated)
|
||||||
|
"198.18.0.0/15", // RFC 2544 benchmarking
|
||||||
|
"198.51.100.0/24", // RFC 5737 TEST-NET-2
|
||||||
|
"203.0.113.0/24", // RFC 5737 TEST-NET-3
|
||||||
|
// IPv6
|
||||||
|
"100::/64", // RFC 6666 discard-only
|
||||||
|
"64:ff9b::/96", // RFC 6052 NAT64 (can embed IPv4 such as 169.254.169.254)
|
||||||
|
"64:ff9b:1::/48", // RFC 8215 local-use NAT64
|
||||||
|
"2001::/32", // RFC 4380 Teredo tunneling (embeds IPv4)
|
||||||
|
"2001:10::/28", // RFC 4843 ORCHID (deprecated)
|
||||||
|
"2001:20::/28", // RFC 7343 ORCHIDv2
|
||||||
|
"2001:db8::/32", // RFC 3849 documentation
|
||||||
|
"2002::/16", // RFC 3056 6to4 (embeds IPv4)
|
||||||
|
} {
|
||||||
|
_, ipNet, err := net.ParseCIDR(cidr)
|
||||||
|
if err != nil {
|
||||||
|
panic("hostmatcher: invalid reserved CIDR " + cidr + ": " + err.Error())
|
||||||
|
}
|
||||||
|
nets = append(nets, ipNet)
|
||||||
|
}
|
||||||
|
return nets
|
||||||
|
})
|
||||||
|
|
||||||
|
// isPrivateIP reports whether ip falls in a private (net.IP.IsPrivate) or reserved special-purpose
|
||||||
|
// range (see reservedIPNets) that must not be considered a public/external destination.
|
||||||
|
func isPrivateIP(ip net.IP) bool {
|
||||||
|
if ip.IsPrivate() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, ipNet := range reservedIPNets() {
|
||||||
|
if ipNet.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// MatchBuiltinPrivate RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) and RFC 4193 (FC00::/7),
|
||||||
|
// plus the reserved special-purpose ranges in reservedIPNets (CGNAT, NAT64, cloud metadata, etc.).
|
||||||
|
// Also called LAN/Intranet.
|
||||||
const MatchBuiltinPrivate = "private"
|
const MatchBuiltinPrivate = "private"
|
||||||
|
|
||||||
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
// MatchBuiltinLoopback 127.0.0.0/8 for IPv4 and ::1/128 for IPv6, localhost is included.
|
||||||
@@ -100,11 +155,11 @@ func (hl *HostMatchList) checkIP(ip net.IP) bool {
|
|||||||
for _, builtin := range hl.builtins {
|
for _, builtin := range hl.builtins {
|
||||||
switch builtin {
|
switch builtin {
|
||||||
case MatchBuiltinExternal:
|
case MatchBuiltinExternal:
|
||||||
if ip.IsGlobalUnicast() && !ip.IsPrivate() {
|
if ip.IsGlobalUnicast() && !isPrivateIP(ip) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinPrivate:
|
case MatchBuiltinPrivate:
|
||||||
if ip.IsPrivate() {
|
if isPrivateIP(ip) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
case MatchBuiltinLoopback:
|
case MatchBuiltinLoopback:
|
||||||
|
|||||||
@@ -159,3 +159,58 @@ func TestHostOrIPMatchesList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
test(cases)
|
test(cases)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestReservedRanges ensures special-purpose ranges that net.IP.IsPrivate misses are kept out of the
|
||||||
|
// "external" allow-list (the default for webhook delivery and repository migrations) and folded into
|
||||||
|
// the "private" block-list, so they cannot be used for SSRF to metadata/internal endpoints.
|
||||||
|
func TestReservedRanges(t *testing.T) {
|
||||||
|
external := ParseHostMatchList("", "external")
|
||||||
|
private := ParseHostMatchList("", "private")
|
||||||
|
|
||||||
|
// legitimate public destinations: external, not private
|
||||||
|
for _, ip := range []string{"8.8.8.8", "1.1.1.1", "2001:4860:4860::8888", "1000::1"} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Truef(t, external.MatchIPAddr(addr), "public ip %s should be external", ip)
|
||||||
|
assert.Falsef(t, private.MatchIPAddr(addr), "public ip %s should not be private", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 1918 / RFC 4193 private ranges (now folded into privateIPNets instead of net.IP.IsPrivate):
|
||||||
|
// not external, blockable as private. Includes range edges to guard the CIDR boundaries.
|
||||||
|
for _, ip := range []string{
|
||||||
|
"10.0.0.0", "10.255.255.255", // 10.0.0.0/8
|
||||||
|
"172.16.0.0", "172.31.255.255", // 172.16.0.0/12
|
||||||
|
"192.168.0.0", "192.168.255.255", // 192.168.0.0/16
|
||||||
|
"fc00::", "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", // fc00::/7
|
||||||
|
} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Falsef(t, external.MatchIPAddr(addr), "private ip %s must not be external", ip)
|
||||||
|
assert.Truef(t, private.MatchIPAddr(addr), "private ip %s should match private block-list", ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 172.32.0.0 is just outside 172.16.0.0/12: a public destination, not private
|
||||||
|
if addr := net.ParseIP("172.32.0.0"); assert.NotNil(t, addr) {
|
||||||
|
assert.True(t, external.MatchIPAddr(addr), "172.32.0.0 should be external")
|
||||||
|
assert.False(t, private.MatchIPAddr(addr), "172.32.0.0 should not be private")
|
||||||
|
}
|
||||||
|
|
||||||
|
// reserved ranges that IsPrivate does not cover: not external, but blockable as private
|
||||||
|
for _, ip := range []string{
|
||||||
|
"100.64.0.1", // CGNAT
|
||||||
|
"100.127.255.254", // CGNAT
|
||||||
|
"168.63.129.16", // Azure WireServer
|
||||||
|
"192.0.2.1", // TEST-NET-1
|
||||||
|
"198.18.0.1", // benchmarking
|
||||||
|
"198.51.100.1", // TEST-NET-2
|
||||||
|
"203.0.113.1", // TEST-NET-3
|
||||||
|
"192.88.99.1", // 6to4 relay anycast
|
||||||
|
"64:ff9b::1", // NAT64
|
||||||
|
"64:ff9b::a9fe:a9fe", // NAT64 embedding 169.254.169.254
|
||||||
|
"2001::1", // Teredo
|
||||||
|
"2002::1", // 6to4
|
||||||
|
"2001:db8::1", // documentation
|
||||||
|
} {
|
||||||
|
addr := net.ParseIP(ip)
|
||||||
|
assert.Falsef(t, external.MatchIPAddr(addr), "reserved ip %s must not be external", ip)
|
||||||
|
assert.Truef(t, private.MatchIPAddr(addr), "reserved ip %s should match private block-list", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user