fix(url_safety): classify the IPv4-translated wrapper by its embedded IPv4

On the reporting macOS host with fake-IP TUN DNS, the resolver answers an IPv4
name with the plain address and its IPv4-translated form ::ffff:0:a.b.c.d
(RFC 2765). ipaddress reads it as unrelated IPv6 space (ipv4_mapped is None),
so a declared security.fake_ip_ranges block could not excuse it — every fetch
on such a host stayed blocked (pre-flight and connect-time) — and the
cloud-metadata floor missed its translated form, leaving it dialable when
allow_private_urls is on.

_embedded_ipv4() extracts the wrapped IPv4 for both wrapper forms; the three
classification sites classify by it.

Fixes #117100.
This commit is contained in:
xielevi
2026-09-20 16:36:38 +08:00
committed by Teknium
parent 30d14a3f73
commit 2163a426c8
2 changed files with 63 additions and 5 deletions

View File

@@ -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."""

View File

@@ -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)