mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-10 07:28:41 -04:00
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.
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user