diff --git a/internal/tools/net.go b/internal/tools/net.go index bc7f7f9..9f38d62 100644 --- a/internal/tools/net.go +++ b/internal/tools/net.go @@ -5,6 +5,15 @@ import ( "net/url" ) +// cgnatRange is the CGNAT shared address space (RFC 6598), not covered by net.IP.IsPrivate(). +// CGNAT (Carrier-Grade NAT) is a technique used by ISPs to conserve IPv4 addresses. Instead of assigning a unique +// public IP to every customer, the ISP places many customers behind a shared NAT, then gives them all addresses +// from the reserved 100.64.0.0/10 range (RFC 6598) on their internal network. +var cgnatRange = func() *net.IPNet { + _, cidr, _ := net.ParseCIDR("100.64.0.0/10") + return cidr +}() + // IsInternalIP checks if the given IP address is an internal IP address (e.g., loopback, private, link-local, or multicast). // IsLoopback — 127.0.0.0/8, ::1 // IsPrivate — 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7 @@ -12,13 +21,15 @@ import ( // IsLinkLocalMulticast — 224.0.0.0/24, ff02::/16 // IsUnspecified — 0.0.0.0, :: // IsMulticast — 224.0.0.0/4, ff00::/8 +// CGNAT — 100.64.0.0/10 (RFC 6598) (Carrier-Grade NAT) func IsInternalIP(ip net.IP) bool { return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsUnspecified() || - ip.IsMulticast() + ip.IsMulticast() || + cgnatRange.Contains(ip) } // IsValidLinkURL checks if the provided string is a valid URL with http or https scheme and a non-empty hostname. diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go index 0451226..e587c1b 100644 --- a/internal/tools/tools_test.go +++ b/internal/tools/tools_test.go @@ -1,10 +1,71 @@ package tools import ( + "net" "reflect" "testing" ) +func TestIsInternalIP(t *testing.T) { + internal := []string{ + "127.0.0.1", // loopback + "::1", // IPv6 loopback + "10.0.0.1", // private + "172.16.0.1", // private + "192.168.1.1", // private + "169.254.1.1", // link-local unicast + "fe80::1", // IPv6 link-local + "0.0.0.0", // unspecified + "224.0.0.1", // multicast + "100.64.0.1", // CGNAT start + "100.127.255.255", // CGNAT end + } + external := []string{ + "8.8.8.8", + "1.1.1.1", + "100.128.0.1", // just outside CGNAT range + } + + for _, s := range internal { + ip := net.ParseIP(s) + if !IsInternalIP(ip) { + t.Errorf("expected %s to be internal", s) + } + } + for _, s := range external { + ip := net.ParseIP(s) + if IsInternalIP(ip) { + t.Errorf("expected %s to be external", s) + } + } +} + +func TestIsValidLinkURL(t *testing.T) { + valid := []string{ + "http://example.com", + "https://example.com", + "https://example.com/path?q=1#anchor", + } + invalid := []string{ + "", + "ftp://example.com", + "example.com", + "//example.com", + "https://", + } + + for _, s := range valid { + if !IsValidLinkURL(s) { + t.Errorf("expected %q to be a valid link URL", s) + } + } + for _, s := range invalid { + if IsValidLinkURL(s) { + t.Errorf("expected %q to be an invalid link URL", s) + } + } +} + func TestArgsParser(t *testing.T) { tests := map[string][]string{} tests["this is a test"] = []string{"this", "is", "a", "test"}