diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 62b684d535..ca004c3d60 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -454,6 +454,46 @@ class TestIPv4MappedIPv6SSRF: assert is_safe_url(url) is False +class TestIPv4TranslatedIPv6SSRF: + """IPv4 answers can arrive wrapped as ``::ffff:0:x.x.x.x`` (RFC 2765 IPv4-translated — on the + reporting macOS host, fake-IP TUN DNS returns it alongside the plain address). + ``IPv6Address.ipv4_mapped`` is None for that form, so declaration coverage, the connect-time + check and the metadata floor must all classify it by the IPv4 it wraps.""" + + @pytest.fixture + def declared(self, monkeypatch): + monkeypatch.setattr( + "hermes_cli.config.read_raw_config", + lambda: {"security": {"fake_ip_ranges": ["198.18.0.0/15"]}}, + ) + _reset_allow_private_cache() + yield + _reset_allow_private_cache() + + def test_declared_sentinel_block_covers_the_translated_wrapper(self, declared): + # ::ffff:0:c612:58 wraps 198.18.0.88 — inside the declared block. + with _resolves_to("::ffff:0:c612:58"): + assert is_safe_url("https://example.com/") is True + with _resolves_to("::ffff:0:c612:58"): + assert _resolved_http_connect_ips("example.com", 443, "https") == ["::ffff:0:c612:58"] + + def test_translated_metadata_wrapper_still_hits_the_floor(self, monkeypatch): + # ::ffff:0:a9fe:a9fe wraps 169.254.169.254; the floor ignores allow_private_urls. + _reset_allow_private_cache() + with _resolves_to("::ffff:0:a9fe:a9fe"): + assert is_always_blocked_url("http://evil.example/") is True + monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true") + _reset_allow_private_cache() + with _resolves_to("::ffff:0:a9fe:a9fe"): + assert is_safe_url("http://evil.example/") is False + _reset_allow_private_cache() + + def test_literal_translated_wrapper_hits_the_floor_without_dns(self): + # Attacker input need not come through DNS: the URL can carry the wrapper itself. + with patch("socket.getaddrinfo", side_effect=socket.gaierror("nope")): + assert is_always_blocked_url("http://[::ffff:0:a9fe:a9fe]/") is True + + class _FakeResponse: """Minimal stand-in for an httpx response as seen inside a response hook.""" diff --git a/tools/url_safety.py b/tools/url_safety.py index e4972fada7..3ecb01123f 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -121,6 +121,11 @@ _MAX_SSRF_CONNECT_IPS = 8 # ipaddress — must be blocked explicitly (Tailscale/WireGuard, cloud internal nets). _CGNAT_NETWORK = ipaddress.ip_network("100.64.0.0/10") +# RFC 2765 IPv4-translated wrapper ``::ffff:0:a.b.c.d`` — the second form resolvers may use to +# answer an IPv4 name (on the reporting macOS host, fake-IP TUN DNS returns it alongside the +# plain address). ``ip.ipv4_mapped`` does not read it; ``_embedded_ipv4`` handles it explicitly. +_IPV4_TRANSLATED_NETWORK = ipaddress.ip_network("::ffff:0:0:0/96") + # Address classes a ``security.fake_ip_ranges`` declaration can never excuse: a local proxy owns # none of them, and a declaration is trusted like ``allow_private_urls`` for whatever it names, # so an entry overlapping one of these (including 0.0.0.0/0 and ::/0) would make real internal @@ -242,7 +247,21 @@ def _getaddrinfo(hostname: str, port: Optional[int] = None): return socket.getaddrinfo(hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM) +def _embedded_ipv4(ip: _IPAddress) -> _IPAddress: + """The IPv4 address an IPv6 wrapper stands for — IPv4-mapped (``::ffff:x.x.x.x``) or + IPv4-translated (``::ffff:0:x.x.x.x``); *ip* unchanged otherwise. ``ipaddress`` reads both + wrappers as distinct IPv6 addresses, so every classification must see through them, or a + resolver's sentinel / a cloud-metadata answer arrives as unrelated IPv6 space.""" + if isinstance(ip, ipaddress.IPv6Address): + if ip.ipv4_mapped is not None: + return ip.ipv4_mapped + if ip in _IPV4_TRANSLATED_NETWORK: + return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF) + return ip + + def _is_always_blocked_ip(ip: _IPAddress) -> bool: + ip = _embedded_ipv4(ip) return ip in _ALWAYS_BLOCKED_IPS or any(ip in net for net in _ALWAYS_BLOCKED_NETWORKS) @@ -250,16 +269,15 @@ def _is_declared_fake_ip(ip: _IPAddress) -> bool: """True when *ip* falls in a block declared in ``security.fake_ip_ranges``. The dial still goes to the local proxy, which resolves and connects to the real target, so a declared block grants no reach an attacker lacks through the proxy's own DNS; undeclared ranges keep the private verdict.""" - if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: - ip = ip.ipv4_mapped + ip = _embedded_ipv4(ip) return any(ip in net for net in _global_fake_ip_ranges()) def _is_blocked_ip(ip: _IPAddress) -> bool: """Return True if the IP should be blocked for SSRF protection.""" - # IPv4-mapped IPv6 (``::ffff:x.x.x.x``) is classified by its embedded IPv4. - if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: - ip = ip.ipv4_mapped + # IPv4-wrapped IPv6 (mapped ``::ffff:x.x.x.x`` or translated ``::ffff:0:x.x.x.x``) is + # classified by its embedded IPv4. + ip = _embedded_ipv4(ip) return (ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified or ip in _CGNAT_NETWORK)