7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
387 lines
16 KiB
Python
387 lines
16 KiB
Python
"""
|
|
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
|