Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cidr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions ip.go
Original file line number Diff line number Diff line change
@@ -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<<bits {
return netip.Addr{}, false
}
value = value<<bits | n
}
return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)}), true
}
86 changes: 67 additions & 19 deletions networkpolicy.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"net/url"
"regexp"
"strconv"
"strings"

"github.com/gaissmai/bart"
iputil "github.com/projectdiscovery/utils/ip"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions networkpolicy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Loading