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:
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user