From 983e1c977117a82c1fad9a76b7a220d1a32d2578 Mon Sep 17 00:00:00 2001 From: Mzack9999 Date: Thu, 29 Oct 2026 14:54:00 +0100 Subject: [PATCH] deny mapped --- cidr.go | 2 + ip.go | 74 ++++++++++++++++++++++++++++ networkpolicy.go | 86 ++++++++++++++++++++++++-------- networkpolicy_test.go | 111 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 19 deletions(-) diff --git a/cidr.go b/cidr.go index 0db2693..828a0c3 100644 --- a/cidr.go +++ b/cidr.go @@ -19,7 +19,9 @@ var DefaultIPv4DenylistRanges = []string{ } var DefaultIPv6DenylistRanges = []string{ + "::/128", // Unspecified address (dials the local host) "::1/128", // Loopback + "::/96", // IPv4-compatible addresses (deprecated, RFC 4291) "64:ff9b::/96", // IPv4/IPv6 translation (RFC 6052) "100::/64", // Discard prefix (RFC 6666) "2001::/32", // Teredo tunneling diff --git a/ip.go b/ip.go index d5fa00e..a361418 100644 --- a/ip.go +++ b/ip.go @@ -1,6 +1,80 @@ package networkpolicy +import ( + "net/netip" + "slices" + "strconv" + "strings" +) + var DefaultIPv4Denylist = []string{ "255.255.255.255", // Broadcast } var DefaultIPv6Denylist = []string{} + +var loopbackAddrs = []netip.Addr{netip.MustParseAddr("127.0.0.1"), netip.IPv6Loopback()} + +// parseAddr parses an IP literal, also in brackets ([::1]) or with a zone (fe80::1%lo0) +func parseAddr(host string) (netip.Addr, bool) { + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + host = host[1 : len(host)-1] + } + addr, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{}, false + } + return addr, true +} + +// hostAddrs returns the addresses that system resolvers and browsers give for a name that is not an IP literal: +// loopback for localhost names, and the legacy IPv4 forms of inet_aton (127.1, 2130706433, 0x7f.0.0.1, 0177.0.0.1). +// A part with a leading zero is octal for glibc and browsers but decimal for the macOS resolver, so both are returned. +func hostAddrs(host string) []netip.Addr { + name := strings.TrimSuffix(strings.ToLower(host), ".") + if name == "localhost" || strings.HasSuffix(name, ".localhost") { + return loopbackAddrs + } + + var addrs []netip.Addr + for _, octal := range []bool{true, false} { + if addr, ok := parseLegacyIPv4(name, octal); ok && !slices.Contains(addrs, addr) { + addrs = append(addrs, addr) + } + } + return addrs +} + +// parseLegacyIPv4 parses one to four numeric parts; the last part fills the remaining bytes +func parseLegacyIPv4(name string, octal bool) (netip.Addr, bool) { + parts := strings.Split(name, ".") + if len(parts) > 4 { + return netip.Addr{}, false + } + var value uint64 + for i, part := range parts { + base := 10 + switch { + case len(part) > 2 && (part[:2] == "0x"): + base, part = 16, part[2:] + case len(part) > 1 && part[0] == '0' && octal: + base, part = 8, part[1:] + } + n, err := strconv.ParseUint(part, base, 32) + if err != nil { + return netip.Addr{}, false + } + if i < len(parts)-1 { + if n > 0xff { + return netip.Addr{}, false + } + value = value<<8 | n + continue + } + bits := 8 * (5 - len(parts)) + if n >= 1<> 24), byte(value >> 16), byte(value >> 8), byte(value)}), true +} diff --git a/networkpolicy.go b/networkpolicy.go index 822bd3f..9d911f3 100644 --- a/networkpolicy.go +++ b/networkpolicy.go @@ -7,6 +7,7 @@ import ( "net/url" "regexp" "strconv" + "strings" "github.com/gaissmai/bart" iputil "github.com/projectdiscovery/utils/ip" @@ -133,21 +134,13 @@ func (r NetworkPolicy) Validate(host string) bool { } // check if it's an ip - if iputil.IsIP(host) { - parsed, err := netip.ParseAddr(host) - if err != nil { - return false - } - - if r.DenyRanger != nil && r.DenyRanger.Size() > 0 && rangerContains(r.DenyRanger, parsed) { - return false - } - - if r.AllowRanger != nil && r.AllowRanger.Size() > 0 { - return rangerContains(r.AllowRanger, parsed) - } + if addr, ok := parseAddr(host); ok { + return r.validateAddr(addr) + } - return true + // names that resolvers and browsers read as an address (127.1, 2130706433, localhost) must not reach a denied range + if r.hostAddrsDenied(host) { + return false } // try to obtain scheme and port @@ -164,6 +157,13 @@ func (r NetworkPolicy) Validate(host string) bool { // urlutil rejects schemes it doesn't know (gopher, dict, file...), which would otherwise skip the scheme and port lists u = URL } + // the host of a URL or host:port value; urlutil keeps some hosts (http://2130706433/) in the path + if u != nil && r.hostAddrsDenied(u.Hostname()) { + return false + } + if URL, err := url.Parse(host); err == nil && r.hostAddrsDenied(URL.Hostname()) { + return false + } if u != nil { // parse scheme scheme = u.Scheme @@ -230,21 +230,63 @@ func (r NetworkPolicy) ValidateURLWithIP(host string, ip string) bool { } func (r NetworkPolicy) ValidateAddress(IP string) bool { - IPDest, err := netip.ParseAddr(IP) - if err != nil { + IPDest, ok := parseAddr(IP) + if !ok { return false } - if r.DenyRanger != nil && r.DenyRanger.Size() > 0 && rangerContains(r.DenyRanger, IPDest) { + return r.validateAddr(IPDest) +} + +// validateAddr checks an address in its canonical form: zone removed and IPv4-mapped IPv6 (::ffff:a.b.c.d) unmapped, +// so IPv4 ranges also match the IPv6 forms that dial the same IPv4 host +func (r NetworkPolicy) validateAddr(addr netip.Addr) bool { + if r.addrDenied(addr) { return false } if r.AllowRanger != nil && r.AllowRanger.Size() > 0 { - return rangerContains(r.AllowRanger, IPDest) + addr = addr.WithZone("") + return rangerContains(r.AllowRanger, addr.Unmap()) || rangerContains(r.AllowRanger, addr) } return true } +// addrDenied checks the address as given and in its canonical form. +// The unspecified address (0.0.0.0, ::) dials the local host, so it is also checked as loopback. +func (r NetworkPolicy) addrDenied(addr netip.Addr) bool { + if r.DenyRanger == nil || r.DenyRanger.Size() == 0 { + return false + } + addr = addr.WithZone("") + canonical := addr.Unmap() + if rangerContains(r.DenyRanger, addr) || rangerContains(r.DenyRanger, canonical) { + return true + } + if canonical.IsUnspecified() { + for _, loopback := range loopbackAddrs { + if rangerContains(r.DenyRanger, loopback) { + return true + } + } + } + return false +} + +// hostAddrsDenied reports whether host is an address form (IP literal, legacy IPv4 notation, localhost name) +// that dials an address in the deny list +func (r NetworkPolicy) hostAddrsDenied(host string) bool { + if addr, ok := parseAddr(host); ok { + return r.addrDenied(addr) + } + for _, addr := range hostAddrs(host) { + if r.addrDenied(addr) { + return true + } + } + return false +} + func (r NetworkPolicy) ValidateAddressWithPort(IP string, port int) bool { return r.ValidateAddress(IP) && r.ValidatePort(port) } @@ -291,9 +333,15 @@ func portIsListed(list map[int]struct{}, port int) bool { // ValidateHost checks all the ips associated to a hostname and returns the valid ip if any func (r NetworkPolicy) ValidateHost(host string) (string, bool) { - if iputil.IsIP(host) { + if addr, ok := parseAddr(host); ok { + if strings.HasPrefix(host, "[") { + host = addr.String() + } return host, r.Validate(host) } + if r.hostAddrsDenied(host) { + return "", false + } // obtain ips from system resolvers addrs, err := net.LookupHost(host) if err != nil { diff --git a/networkpolicy_test.go b/networkpolicy_test.go index a7f2e37..117cfa2 100644 --- a/networkpolicy_test.go +++ b/networkpolicy_test.go @@ -241,3 +241,114 @@ func TestValidateScheme(t *testing.T) { require.NoError(t, err) require.False(t, ports.Validate("gopher://example.com:6379/_x")) } + +func TestValidateAddressForms(t *testing.T) { + var denyList []string + denyList = append(denyList, DefaultIPv4DenylistRanges...) + denyList = append(denyList, DefaultIPv6DenylistRanges...) + np, err := New(Options{DenyList: denyList}) + require.NoError(t, err) + + // IP literals that dial a denied IPv4 or IPv6 range + for _, ip := range []string{ + "::ffff:127.0.0.1", + "::ffff:7f00:1", + "0:0:0:0:0:ffff:127.0.0.1", + "::ffff:169.254.169.254", + "::ffff:10.0.0.1", + "::", + "[::]", + "[::ffff:127.0.0.1]", + "::127.0.0.1", + "fe80::1%lo0", + "::1%1", + } { + require.False(t, np.Validate(ip), "Validate should deny %s", ip) + require.False(t, np.ValidateAddress(ip), "ValidateAddress should deny %s", ip) + _, ok := np.ValidateHost(ip) + require.False(t, ok, "ValidateHost should deny %s", ip) + require.False(t, np.ValidateURLWithIP("example.com", ip), "ValidateURLWithIP should deny %s", ip) + } + + // names, URLs and host:port values that dial a denied range + for _, host := range []string{ + "127.1", + "2130706433", + "0x7f.0.0.1", + "0177.0.0.1", + "0x7f000001", + "0xa9.0xfe.0xa9.0xfe", + "localhost", + "LOCALHOST.", + "foo.localhost", + "http://127.0.0.1/", + "http://[::ffff:127.0.0.1]:18081/x", + "http://[::]:18082/", + "http://2130706433/", + "http://localhost:8080/", + "127.0.0.1:80", + "[::1]:80", + "[::ffff:169.254.169.254]:80", + } { + require.False(t, np.Validate(host), "Validate should deny %s", host) + } + + // public destinations stay allowed + for _, host := range []string{ + "8.8.8.8", + "::ffff:8.8.8.8", + "2404:6800:4002:81c::200e", + "http://8.8.8.8/", + "http://[::ffff:8.8.8.8]:80/", + "8.8.8.8:53", + "134744072", + "example.com", + "http://example.com:8080/", + "1.2.3.4.example.com", + "localhost.example.com", + } { + require.True(t, np.Validate(host), "Validate should allow %s", host) + } + require.True(t, np.ValidateAddress("::ffff:8.8.8.8")) +} + +func TestValidateExcludedIPv4Forms(t *testing.T) { + // same as an exclude list with one IPv4 address + np, err := New(Options{DenyList: []string{"127.0.0.1"}}) + require.NoError(t, err) + + for _, host := range []string{"::ffff:127.0.0.1", "[::ffff:7f00:1]", "0.0.0.0", "::", "http://[::ffff:127.0.0.1]:18081/via-redirect", "127.1"} { + require.False(t, np.Validate(host), "Validate should deny %s", host) + } + require.False(t, np.ValidateAddress("::ffff:127.0.0.1")) + require.False(t, np.ValidateAddress("::")) + require.True(t, np.Validate("::ffff:127.0.0.2")) + require.True(t, np.Validate("8.8.8.8")) +} + +func TestValidateAllowListMappedForms(t *testing.T) { + np, err := New(Options{AllowList: []string{"10.0.0.0/8"}}) + require.NoError(t, err) + + require.True(t, np.Validate("::ffff:10.0.0.1")) + require.True(t, np.ValidateAddress("[::ffff:10.0.0.1]")) + require.False(t, np.Validate("::ffff:8.8.8.8")) + require.False(t, np.ValidateAddress("8.8.8.8")) +} + +func TestValidateHostnameDenyRule(t *testing.T) { + np, err := New(Options{DenyList: DefaultHostDenylist}) + require.NoError(t, err) + + require.False(t, np.Validate("localhost")) + require.True(t, np.Validate("example.com")) +} + +func TestValidateHostBracketedIP(t *testing.T) { + np, err := New(Options{DenyList: []string{"10.0.0.0/8"}}) + require.NoError(t, err) + + ip, ok := np.ValidateHost("[::1]") + require.True(t, ok) + require.Equal(t, "::1", ip) +}