""" Tests for security/ip_ranges.py Tests cover: - PRIVATE_IP_RANGES constant contains expected networks - Private IP detection works correctly - IPv4 and IPv6 address validation """ import ipaddress def _ip_is_private(ip_str: str) -> bool: """Module-level helper used across the IPv6-transition-prefix test classes. (TestPrivateIPDetection has its own copy that returns False for invalid IPs; this one is for callers that pass only well-formed addresses.)""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES ip = ipaddress.ip_address(ip_str) return any(ip in network for network in PRIVATE_IP_RANGES) class TestPrivateIPRanges: """Tests for PRIVATE_IP_RANGES constant.""" def test_private_ip_ranges_is_list(self): """PRIVATE_IP_RANGES should be a list.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES assert isinstance(PRIVATE_IP_RANGES, list) def test_private_ip_ranges_not_empty(self): """PRIVATE_IP_RANGES should not be empty.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES assert len(PRIVATE_IP_RANGES) > 0 def test_private_ip_ranges_contains_ip_networks(self): """All entries should be ip_network objects.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES for network in PRIVATE_IP_RANGES: assert isinstance( network, (ipaddress.IPv4Network, ipaddress.IPv6Network) ) def test_contains_loopback_ipv4(self): """Should contain IPv4 loopback (127.0.0.0/8).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES loopback = ipaddress.ip_network("127.0.0.0/8") assert loopback in PRIVATE_IP_RANGES def test_contains_loopback_ipv6(self): """Should contain IPv6 loopback (::1/128).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES loopback = ipaddress.ip_network("::1/128") assert loopback in PRIVATE_IP_RANGES def test_contains_rfc1918_class_a(self): """Should contain RFC1918 Class A private (10.0.0.0/8).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES private_a = ipaddress.ip_network("10.0.0.0/8") assert private_a in PRIVATE_IP_RANGES def test_contains_rfc1918_class_b(self): """Should contain RFC1918 Class B private (172.16.0.0/12).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES private_b = ipaddress.ip_network("172.16.0.0/12") assert private_b in PRIVATE_IP_RANGES def test_contains_rfc1918_class_c(self): """Should contain RFC1918 Class C private (192.168.0.0/16).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES private_c = ipaddress.ip_network("192.168.0.0/16") assert private_c in PRIVATE_IP_RANGES def test_contains_cgnat(self): """Should contain CGNAT range (100.64.0.0/10).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES cgnat = ipaddress.ip_network("100.64.0.0/10") assert cgnat in PRIVATE_IP_RANGES def test_contains_link_local_ipv4(self): """Should contain IPv4 link-local (169.254.0.0/16).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES link_local = ipaddress.ip_network("169.254.0.0/16") assert link_local in PRIVATE_IP_RANGES def test_contains_link_local_ipv6(self): """Should contain IPv6 link-local (fe80::/10).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES link_local = ipaddress.ip_network("fe80::/10") assert link_local in PRIVATE_IP_RANGES def test_contains_ipv6_unique_local(self): """Should contain IPv6 unique local (fc00::/7).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES unique_local = ipaddress.ip_network("fc00::/7") assert unique_local in PRIVATE_IP_RANGES def test_contains_ipv4_unspecified(self): """Should contain 0.0.0.0/8 ('this' network — IPv4 unspecified). Linux routes connect() to 0.0.0.0 to local host.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES unspecified_v4 = ipaddress.ip_network("0.0.0.0/8") assert unspecified_v4 in PRIVATE_IP_RANGES def test_contains_ipv6_unspecified(self): """Should contain ::/128 (IPv6 unspecified). Linux routes connect() to [::] to local host (same semantics as 0.0.0.0). Added in PR #3873 after empirical bypass discovery.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES unspecified_v6 = ipaddress.ip_network("::/128") assert unspecified_v6 in PRIVATE_IP_RANGES def test_contains_6to4_prefix(self): """Should contain 2002::/16 (6to4 transition prefix). Wraps private IPv4 destinations on hosts with sit0 routes.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES sixto4 = ipaddress.ip_network("2002::/16") assert sixto4 in PRIVATE_IP_RANGES def test_contains_nat64_prefix(self): """Should contain 64:ff9b::/96 (NAT64 well-known prefix).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES nat64 = ipaddress.ip_network("64:ff9b::/96") assert nat64 in PRIVATE_IP_RANGES def test_contains_nat64_local_use_prefix(self): """Should contain 64:ff9b:1::/48 (RFC 8215 NAT64 local-use prefix) — same SSRF threat class as the well-known prefix; missing it is the exact bypass paid out as a HackerOne bounty against ssrf_filter.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES nat64_local = ipaddress.ip_network("64:ff9b:1::/48") assert nat64_local in PRIVATE_IP_RANGES def test_nat64_prefixes_constant_exposes_both(self): """NAT64_PREFIXES must contain exactly the two NAT64 prefixes — used by validators to identify which deny entries the security.allow_nat64 env carve-out should skip.""" from local_deep_research.security.ip_ranges import NAT64_PREFIXES assert ipaddress.ip_network("64:ff9b::/96") in NAT64_PREFIXES assert ipaddress.ip_network("64:ff9b:1::/48") in NAT64_PREFIXES assert len(NAT64_PREFIXES) == 2 def test_contains_teredo_prefix(self): """Should contain 2001::/32 (Teredo).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES teredo = ipaddress.ip_network("2001::/32") assert teredo in PRIVATE_IP_RANGES def test_contains_ipv6_discard_prefix(self): """Should contain 100::/64 (RFC 6666 discard).""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES discard = ipaddress.ip_network("100::/64") assert discard in PRIVATE_IP_RANGES def test_contains_ipv4_compatible_ipv6_prefix(self): """Should contain ::/96 (RFC 4291 IPv4-Compatible IPv6 — DEPRECATED). Same SSRF threat class as the transition prefixes: embeds an IPv4 address in the low 32 bits and is routable on hosts with ::/96 routes configured. [::169.254.169.254] would otherwise reach IMDS.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES ipv4_compat = ipaddress.ip_network("::/96") assert ipv4_compat in PRIVATE_IP_RANGES class TestPrivateIPDetection: """Tests for using PRIVATE_IP_RANGES to detect private IPs.""" def _is_private(self, ip_str: str) -> bool: """Helper to check if IP is in private ranges.""" from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES try: ip = ipaddress.ip_address(ip_str) return any(ip in network for network in PRIVATE_IP_RANGES) except ValueError: return False def test_localhost_is_private(self): """127.0.0.1 should be detected as private.""" assert self._is_private("127.0.0.1") is True def test_localhost_ipv6_is_private(self): """::1 should be detected as private.""" assert self._is_private("::1") is True def test_10_network_is_private(self): """10.x.x.x addresses should be detected as private.""" assert self._is_private("10.0.0.1") is True assert self._is_private("10.255.255.255") is True def test_172_16_network_is_private(self): """172.16-31.x.x addresses should be detected as private.""" assert self._is_private("172.16.0.1") is True assert self._is_private("172.31.255.255") is True def test_172_32_is_not_private(self): """172.32.x.x should NOT be detected as private.""" assert self._is_private("172.32.0.1") is False def test_192_168_network_is_private(self): """192.168.x.x addresses should be detected as private.""" assert self._is_private("192.168.0.1") is True assert self._is_private("192.168.255.255") is True def test_cgnat_is_private(self): """100.64.x.x CGNAT addresses should be detected as private.""" assert self._is_private("100.64.0.1") is True assert self._is_private("100.127.255.255") is True def test_link_local_is_private(self): """169.254.x.x link-local addresses should be detected as private.""" assert self._is_private("169.254.1.1") is True def test_public_ip_is_not_private(self): """Public IPs should NOT be detected as private.""" assert self._is_private("8.8.8.8") is False assert self._is_private("1.1.1.1") is False assert self._is_private("93.184.216.34") is False # example.com def test_ipv6_public_is_not_private(self): """Public IPv6 addresses should NOT be detected as private.""" assert self._is_private("2001:4860:4860::8888") is False # Google DNS class TestIPv6TransitionPrefixesAntiCollision: """Anti-regression: confirm the new IPv6 transition-prefix entries do NOT swallow legitimate global IPv6 allocations at their boundaries. Each prefix is precisely scoped at the bit level: - 2001::/32 fixes the second hextet to 0x0000 (Teredo only). - 2002::/16 fixes the first hextet to 0x2002 (6to4 only). - 64:ff9b::/96 fixes the first 96 bits (the well-known NAT64 prefix only; RFC 8215 local-use 64:ff9b:1::/48 must NOT match). - 100::/64 fixes the first 64 bits (RFC 6666 discard only; the rest of 100::/8 is unallocated, not discard). """ _is_private = staticmethod(_ip_is_private) def test_google_dns_v6_not_blocked(self): """2001:4860:4860::8888 — second hextet 0x4860, outside 2001::/32.""" assert self._is_private("2001:4860:4860::8888") is False def test_cloudflare_dns_v6_not_blocked(self): """2606:4700:4700::1111 — first hextet 0x2606, far from 2001/2002.""" assert self._is_private("2606:4700:4700::1111") is False def test_documentation_prefix_v6_not_blocked(self): """2001:db8::/32 (RFC 3849) — second hextet 0x0db8, outside Teredo.""" assert self._is_private("2001:db8::1") is False def test_root_server_v6_not_blocked(self): """2001:500::/30 (root-server allocation) — second hextet 0x0500.""" assert self._is_private("2001:500:88::1") is False def test_he_tunnelbroker_v6_not_blocked(self): """2001:470::/32 (Hurricane Electric) — second hextet 0x0470.""" assert self._is_private("2001:470:1f04::1") is False def test_neighbor_above_6to4_not_blocked(self): """2003::/16 (Deutsche Telekom) sits adjacent to 2002::/16.""" assert self._is_private("2003::1") is False def test_neighbor_below_6to4_not_blocked(self): """2001:ffff::1 — last address of 2001:: space, not in 2002::/16.""" assert self._is_private("2001:ffff::1") is False # NOTE: RFC 8215's 64:ff9b:1::/48 (NAT64 local-use) IS now blocked — # see TestPrivateIPRanges::test_contains_nat64_local_use_prefix. It # is the same SSRF threat class as the well-known /96 and missing # it has been paid out as a HackerOne bounty against ssrf_filter. def test_ipv6_discard_prefix_neighbor_not_blocked(self): """100:1::/64 — second hextet 0x0001, outside the /64 discard block. The surrounding 100::/8 is reserved-unallocated, not discard, so we must not over-block it.""" assert self._is_private("100:1::1") is False class TestIPv6TransitionPrefixesPositiveDetection: """Confirm the new transition prefixes detect their full address space, including embedded private-IPv4 wraps relevant to SSRF.""" _is_private = staticmethod(_ip_is_private) def test_6to4_wraps_loopback(self): """[2002:7f00:1::] — 6to4 wrap of 127.0.0.1.""" assert self._is_private("2002:7f00:1::") is True def test_6to4_wraps_rfc1918_class_a(self): """[2002:0a00:1::] — 6to4 wrap of 10.0.0.1.""" assert self._is_private("2002:0a00:1::") is True def test_6to4_wraps_rfc1918_class_b(self): """[2002:ac10:1::] — 6to4 wrap of 172.16.0.1.""" assert self._is_private("2002:ac10:1::") is True def test_6to4_wraps_rfc1918_class_c(self): """[2002:c0a8:101::] — 6to4 wrap of 192.168.1.1.""" assert self._is_private("2002:c0a8:101::") is True def test_6to4_wraps_aws_metadata(self): """[2002:a9fe:a9fe::] — 6to4 wrap of 169.254.169.254 (AWS IMDS). High-value SSRF target; must be caught by the prefix block.""" assert self._is_private("2002:a9fe:a9fe::") is True def test_6to4_upper_boundary(self): """Last address in 2002::/16.""" assert ( self._is_private("2002:ffff:ffff:ffff:ffff:ffff:ffff:ffff") is True ) def test_nat64_wraps_loopback(self): """[64:ff9b::7f00:1] — NAT64 wrap of 127.0.0.1.""" assert self._is_private("64:ff9b::7f00:1") is True def test_nat64_wraps_rfc1918_class_a(self): """[64:ff9b::a00:1] — NAT64 wrap of 10.0.0.1.""" assert self._is_private("64:ff9b::a00:1") is True def test_nat64_wraps_rfc1918_class_b(self): """[64:ff9b::ac10:1] — NAT64 wrap of 172.16.0.1.""" assert self._is_private("64:ff9b::ac10:1") is True def test_nat64_wraps_aws_metadata(self): """[64:ff9b::a9fe:a9fe] — NAT64 wrap of 169.254.169.254 (AWS IMDS).""" assert self._is_private("64:ff9b::a9fe:a9fe") is True def test_teredo_lower_boundary(self): """2001:0:0:0:0:0:0:0 — first address in 2001::/32.""" assert self._is_private("2001::") is True def test_teredo_upper_boundary(self): """2001:0:ffff:ffff:ffff:ffff:ffff:ffff — last address in 2001::/32.""" assert self._is_private("2001:0:ffff:ffff:ffff:ffff:ffff:ffff") is True def test_discard_prefix_upper_boundary(self): """Last address in 100::/64.""" assert self._is_private("100::ffff:ffff:ffff:ffff") is True class TestIPRangesUsedByValidators: """Tests to verify PRIVATE_IP_RANGES is correctly imported by validators.""" def test_ssrf_validator_uses_shared_ranges(self): """SSRF validator should import BLOCKED_IP_RANGES from ip_ranges.""" from local_deep_research.security.ssrf_validator import ( BLOCKED_IP_RANGES, ) from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES # Should be the same object (imported, not copied) assert BLOCKED_IP_RANGES is PRIVATE_IP_RANGES def test_notification_validator_uses_shared_ranges(self): """Notification validator should use shared ranges.""" from local_deep_research.security.notification_validator import ( NotificationURLValidator, ) from local_deep_research.security.ip_ranges import PRIVATE_IP_RANGES # Should be the same object assert NotificationURLValidator.PRIVATE_IP_RANGES is PRIVATE_IP_RANGES