From b3f8b7731797976e5a8e621ee2d7427c4c922297 Mon Sep 17 00:00:00 2001 From: Joeseph Grey <212606152+StressTestor@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:36:27 -0600 Subject: [PATCH] fix(url-safety): reject RFC 6598 shared address space in strict mode (#5474) * security(url-safety): reject RFC 6598 shared address space in strict mode Strict mode (block_private=True) is a full SSRF lockdown, but it only rejected is_private and is_loopback targets. CPython does not classify RFC 6598 shared/CGNAT space (100.64.0.0/10) as is_private (it is "shared", not "private"), so a public redirect into 100.64.0.1 passed the per-hop guard and still issued the request to a potentially internal CGNAT service. not is_global would also exclude it, but only on CPython 3.11.10+/3.12.4+/ 3.13+; the CI matrix runs 3.11/3.12, so reject the range explicitly to stay correct across patch levels and the 3.14 runtime image. Default local-first mode is unchanged. Adds strict-mode coverage for shared, non-global, and public targets. * docs(url-safety): correct CGNAT is_global rationale in strict-mode comment The prior comment claimed `not is_global` catches 100.64.0.0/10 only on CPython 3.11.10+/3.12.4+/3.13+. That is inaccurate for CGNAT: is_global is False for 100.64.0.1 on every supported version (verified 3.10-3.14). The version-fragility applies to other ranges gh-113171 touched, not CGNAT. The explicit range reject is still the right choice; restate the reason as is_private not covering shared space, and not coupling strict mode to is_global's broader, cross-version definition. No behavior change. --- src/url_safety.py | 16 ++++++++++++++-- tests/test_url_safety.py | 27 +++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/url_safety.py b/src/url_safety.py index f85bff000..c8bf86327 100644 --- a/src/url_safety.py +++ b/src/url_safety.py @@ -25,6 +25,14 @@ from urllib.parse import urlparse ALLOWED_SCHEMES = ("http", "https") +# RFC 6598 shared address space (carrier-grade NAT). It is not globally +# routable, but CPython does not classify it as ``is_private`` (it is "shared", +# not "private"), so the is_private/is_loopback checks miss it. Reject the range +# explicitly. This closes exactly the shared-space gap without coupling strict +# mode to ``is_global``'s broader definition, which has shifted across CPython +# versions for other special ranges. +_SHARED_ADDRESS_SPACE_V4 = ipaddress.ip_network("100.64.0.0/10") + def _default_resolver(host: str) -> List[str]: """Resolve a hostname to the list of IP strings it maps to (A + AAAA).""" @@ -40,8 +48,12 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st return f"link-local address blocked (SSRF metadata risk): {ip}" if ip.is_multicast or ip.is_reserved or ip.is_unspecified: return f"disallowed address: {ip}" - if block_private and (ip.is_private or ip.is_loopback): - return f"private/loopback address blocked: {ip}" + if block_private and ( + ip.is_private + or ip.is_loopback + or (isinstance(ip, ipaddress.IPv4Address) and ip in _SHARED_ADDRESS_SPACE_V4) + ): + return f"private/shared/loopback address blocked: {ip}" return None diff --git a/tests/test_url_safety.py b/tests/test_url_safety.py index faae6b86b..d82d38878 100644 --- a/tests/test_url_safety.py +++ b/tests/test_url_safety.py @@ -64,6 +64,33 @@ def test_strict_mode_blocks_private_and_loopback(): assert ok is False and "private" in reason +def test_strict_mode_blocks_cgnat_shared_space(): + # RFC 6598 shared/CGNAT space (100.64.0.0/10) is not globally routable. + # A public redirect into it must be rejected under full SSRF lockdown, + # even though ipaddress reports is_private=False for this range. + CGNAT = _resolver({"svc.example": ["100.64.0.1"]}) + ok, reason = check_outbound_url("http://svc.example:8080", block_private=True, resolver=CGNAT) + assert ok is False + assert "blocked" in reason + + +def test_strict_mode_blocks_non_global_ranges(): + # Strict mode is a full SSRF lockdown: only globally-routable public + # addresses may be reached. Benchmarking (198.18.0.0/15) and TEST-NET + # documentation space (192.0.2.0/24) are not globally routable. + for ip in ("198.18.0.1", "192.0.2.10"): + res = _resolver({"svc.example": [ip]}) + ok, reason = check_outbound_url("http://svc.example", block_private=True, resolver=res) + assert ok is False, ip + assert "blocked" in reason + + +def test_strict_mode_still_allows_public_ip(): + # The lockdown must not reject a legitimate globally-routable target. + ok, reason = check_outbound_url("https://example.com/v1", block_private=True, resolver=PUBLIC) + assert ok is True, reason + + def test_unresolvable_host_blocked(): ok, reason = check_outbound_url("http://does-not-resolve.invalid", resolver=PUBLIC) assert ok is False