fix: judge the IPv4 tunnelled inside IPv6 transition addresses

The SSRF guard classified addresses with ipaddress.is_global, which looks only
at the outer address. An IPv6 form that carries an IPv4 address at a fixed
offset therefore passed a check the bare address would have failed: the NAT64
well-known prefix 64:ff9b::/96 sits in the 2000::/3 global unicast range, so
64:ff9b::a9fe:a9fe was accepted while 169.254.169.254 was refused. The
deprecated IPv4-compatible form ::/96 has the same property. Both the ingress
validator and the connect-time socket guard classified through the same helper,
so both were affected.

Judge every address a verdict has to account for: the outer address plus any
IPv4 it tunnels, allowed only when all of them are global. Unwrapping this way
can only tighten the verdict, which matters for 6to4 and Teredo — Python
already rejects 2002::/16 and 2001::/32 wholesale, and replacing the outer
address with its payload would have turned 2002:0808:0808:: from blocked into
allowed. Reaching an internal service this way additionally requires NAT64
routing on the host network, which the attacker does not control.

Reported by tonghuaroot in GHSA-5mq5-qr7m-f4wx.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Alex Shnitman
2026-08-15 16:37:57 +02:00
parent 482381d6b9
commit 7082858237
2 changed files with 102 additions and 8 deletions
+60 -8
View File
@@ -61,6 +61,19 @@ def _hostname_is_blocked(hostname: str) -> bool:
return False
# IPv6 ranges that tunnel an IPv4 address at a fixed offset. ``is_global``
# judges only the outer address, so an internal IPv4 wrapped in one of these can
# pass a check the bare address would fail — 64:ff9b::a9fe:a9fe carries the cloud
# metadata address but sits in the 2000::/3 global unicast range.
_NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network('64:ff9b::/96')
_IPV4_COMPATIBLE = ipaddress.ip_network('::/96')
# ``::`` and ``::1`` sit inside ::/96 without being IPv4-compatible addresses
# (RFC 4291 reserves both), and 0.0.0.0/8 is not a routable destination anyway.
# Reading a tunnelled address out of them would just misdescribe them.
_UNUSABLE_IPV4 = ipaddress.ip_network('0.0.0.0/8')
def _normalise_ip(addr: str):
"""Parse *addr*, unwrapping IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``)
so the embedded IPv4 address is judged on its own merits. Returns ``None``
@@ -74,9 +87,46 @@ def _normalise_ip(addr: str):
return ip
def _address_is_global(addr: str) -> bool:
def _tunnelled_ipv4(ip):
"""The IPv4 address an IPv6 transition form tunnels, or ``None``.
Covers 6to4 (``2002::/16``), Teredo (``2001::/32``), the NAT64 well-known
prefix (``64:ff9b::/96``) and the deprecated IPv4-compatible form
(``::/96``). IPv4-mapped is handled by ``_normalise_ip`` instead: that form
*is* its embedded address rather than a tunnel to it.
"""
if not isinstance(ip, ipaddress.IPv6Address):
return None
if ip.sixtofour is not None:
return ip.sixtofour
if ip.teredo is not None:
return ip.teredo[1]
if ip in _NAT64_WELL_KNOWN_PREFIX or ip in _IPV4_COMPATIBLE:
tunnelled = ipaddress.ip_address(int(ip) & 0xFFFFFFFF)
return None if tunnelled in _UNUSABLE_IPV4 else tunnelled
return None
def _ips_to_judge(addr: str) -> tuple:
"""Every address a verdict on *addr* has to account for: the address itself
plus any IPv4 it tunnels. Empty when *addr* is not a valid IP literal.
A tunnelled address is judged on *both* halves, so unwrapping can only ever
tighten the verdict. Returning the embedded address alone would be a way in:
Python already rejects all of 2002::/16 and 2001::/32, and replacing
``2002:0808:0808::`` with the global 8.8.8.8 would turn an address the guard
blocks today into an allowed one.
"""
ip = _normalise_ip(addr)
return ip is not None and ip.is_global
if ip is None:
return ()
tunnelled = _tunnelled_ipv4(ip)
return (ip,) if tunnelled is None else (ip, tunnelled)
def _address_is_global(addr: str) -> bool:
ips = _ips_to_judge(addr)
return bool(ips) and all(ip.is_global for ip in ips)
def _address_allowed_at_connect(addr: str, allow_loopback: bool = False) -> bool:
@@ -91,10 +141,12 @@ def _address_allowed_at_connect(addr: str, allow_loopback: bool = False) -> bool
169.254.169.254), private (RFC1918), unique-local and every other non-global
range.
"""
ip = _normalise_ip(addr)
if ip is None:
ips = _ips_to_judge(addr)
if not ips:
return False
return ip.is_global or (allow_loopback and ip.is_loopback)
if all(ip.is_global for ip in ips):
return True
return allow_loopback and all(ip.is_loopback for ip in ips)
def _proxy_endpoint(proxy_url: str):
@@ -177,9 +229,9 @@ def install_socket_guard(allow_private: bool = False, proxy_urls=()) -> None:
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
the ``*_proxy`` environment variables are picked up automatically). A proxy on
loopback is reachable at its own host:port, and nothing else on loopback is.
That costs proxied setups nothing: yt-dlp resolves the proxy itself at exactly
that host:port, and a media URL is either handed to the proxy unresolved or
resolved on its own merits — never inheriting the proxy's allowance.
That costs proxied setups nothing: yt-dlp resolves the proxy itself at exactly that host:port,
and a media URL is either handed to the proxy unresolved or resolved on its own
merits — never inheriting the proxy's allowance.
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
installed at all, so proxy/VPN setups that route through private or Fake-IP