From 87ab37338150183f3187e93afb49aab108f8a9cd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 21 Jun 2026 13:38:32 -0700 Subject: [PATCH] test(url-safety): cover IPv6 scope-ID strip + fail-closed in URL guards Follow-up to the salvaged #25961 fix: regression tests asserting that scope-bearing IPv6 addresses (fe80::1%eth0, ::1%lo) are blocked by is_safe_url after the scope is stripped, that a still-unparseable address fails closed, and that a scoped IPv4-mapped IMDS address is caught by the always-blocked floor. --- tests/tools/test_url_safety.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index c68dd6e82dc..dc5a7e52acc 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -164,6 +164,31 @@ class TestIsSafeUrl: ]): assert is_safe_url("http://[::ffff:169.254.169.254]/") is False + def test_ipv6_scope_id_link_local_blocked(self): + """fe80::1%eth0 — a scope-ID-bearing link-local address must not bypass + the guard. ``ipaddress.ip_address`` rejects the ``%scope`` suffix, so + the scope must be stripped before the block check rather than skipped. + """ + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("fe80::1%eth0", 0, 0, 0)), + ]): + assert is_safe_url("http://[fe80::1%eth0]/") is False + + def test_ipv6_scope_id_loopback_blocked(self): + """::1%lo — scoped IPv6 loopback must still be blocked.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::1%lo", 0, 0, 0)), + ]): + assert is_safe_url("http://[::1%lo]/") is False + + def test_unparseable_ip_after_scope_strip_fails_closed(self): + """An address that is still unparseable after stripping the scope ID + must fail closed (block), not be silently skipped.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("not-an-ip%garbage", 0, 0, 0)), + ]): + assert is_safe_url("http://example.invalid/") is False + def test_unspecified_address_blocked(self): """0.0.0.0 — unspecified address, can bind to all interfaces.""" with patch("socket.getaddrinfo", return_value=[ @@ -492,6 +517,15 @@ class TestIsAlwaysBlockedUrl: ]): assert is_always_blocked_url("http://attacker-controlled.example.com/") is True + def test_scope_id_imds_in_floor_blocked(self): + """A scope-ID suffix on an IPv4-mapped IMDS address resolving in the + always-blocked floor must be caught after the scope is stripped, not + skipped as unparseable.""" + with patch("socket.getaddrinfo", return_value=[ + (10, 1, 6, "", ("::ffff:169.254.169.254%eth0", 0, 0, 0)), + ]): + assert is_always_blocked_url("http://attacker-controlled.example.com/") is True + # -- Things the floor must NOT block ---------------------------------------- def test_public_url_not_blocked(self):