mirror of
https://github.com/alexta69/metube.git
synced 2026-09-21 13:35:01 +00:00
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:
@@ -10,6 +10,7 @@ import url_guard
|
|||||||
from url_guard import (
|
from url_guard import (
|
||||||
validate_url,
|
validate_url,
|
||||||
_address_allowed_at_connect,
|
_address_allowed_at_connect,
|
||||||
|
_address_is_global,
|
||||||
_guarded_getaddrinfo,
|
_guarded_getaddrinfo,
|
||||||
_proxy_endpoint,
|
_proxy_endpoint,
|
||||||
install_socket_guard,
|
install_socket_guard,
|
||||||
@@ -138,6 +139,47 @@ class ConnectAddressPolicyTests(unittest.TestCase):
|
|||||||
self.assertFalse(_address_allowed_at_connect("::ffff:169.254.169.254"))
|
self.assertFalse(_address_allowed_at_connect("::ffff:169.254.169.254"))
|
||||||
|
|
||||||
|
|
||||||
|
class TunnelledIPv4Tests(unittest.TestCase):
|
||||||
|
"""IPv6 transition forms that carry an IPv4 address the outer address hides.
|
||||||
|
|
||||||
|
``is_global`` looks only at the outer address, so a form that tunnels an
|
||||||
|
internal IPv4 has to be unwrapped before it is judged (GHSA-5mq5-qr7m-f4wx).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_nat64_well_known_prefix_blocked(self):
|
||||||
|
# 2000::/3 global unicast on its face; carries the metadata address.
|
||||||
|
self.assertFalse(_address_is_global("64:ff9b::a9fe:a9fe"))
|
||||||
|
self.assertFalse(_address_is_global("64:ff9b::7f00:1"))
|
||||||
|
self.assertFalse(_address_allowed_at_connect("64:ff9b::a9fe:a9fe"))
|
||||||
|
|
||||||
|
def test_nat64_carrying_a_public_address_allowed(self):
|
||||||
|
self.assertTrue(_address_is_global("64:ff9b::8.8.8.8"))
|
||||||
|
|
||||||
|
def test_ipv4_compatible_form_blocked(self):
|
||||||
|
# The deprecated ::/96 form, likewise global-looking to is_global.
|
||||||
|
self.assertFalse(_address_is_global("::a9fe:a9fe"))
|
||||||
|
self.assertFalse(_address_allowed_at_connect("::a9fe:a9fe"))
|
||||||
|
|
||||||
|
def test_sixtofour_and_teredo_stay_blocked(self):
|
||||||
|
# Python rejects these ranges wholesale. Unwrapping must not promote a
|
||||||
|
# blocked address to an allowed one just because the payload is global.
|
||||||
|
self.assertFalse(_address_is_global("2002:a9fe:a9fe::"))
|
||||||
|
self.assertFalse(_address_is_global("2002:0808:0808::"))
|
||||||
|
self.assertFalse(_address_is_global("2001:0:4136:e378:8000:63bf:3fff:fdd2"))
|
||||||
|
|
||||||
|
def test_plain_addresses_unaffected(self):
|
||||||
|
self.assertTrue(_address_is_global("142.250.1.1"))
|
||||||
|
self.assertTrue(_address_is_global("2607:f8b0:4004:c07::64"))
|
||||||
|
self.assertFalse(_address_is_global("not-an-ip"))
|
||||||
|
|
||||||
|
def test_tunnelled_form_blocked_at_ingress(self):
|
||||||
|
with mock.patch(
|
||||||
|
"url_guard.socket.getaddrinfo",
|
||||||
|
return_value=_addrinfo("64:ff9b::a9fe:a9fe", family=socket.AF_INET6),
|
||||||
|
):
|
||||||
|
self.assertIsNotNone(validate_url("http://nat64.example/x"))
|
||||||
|
|
||||||
|
|
||||||
class ProxyEndpointParsingTests(unittest.TestCase):
|
class ProxyEndpointParsingTests(unittest.TestCase):
|
||||||
def test_explicit_port(self):
|
def test_explicit_port(self):
|
||||||
self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050))
|
||||||
|
|||||||
+60
-8
@@ -61,6 +61,19 @@ def _hostname_is_blocked(hostname: str) -> bool:
|
|||||||
return False
|
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):
|
def _normalise_ip(addr: str):
|
||||||
"""Parse *addr*, unwrapping IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``)
|
"""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``
|
so the embedded IPv4 address is judged on its own merits. Returns ``None``
|
||||||
@@ -74,9 +87,46 @@ def _normalise_ip(addr: str):
|
|||||||
return ip
|
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)
|
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:
|
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
|
169.254.169.254), private (RFC1918), unique-local and every other non-global
|
||||||
range.
|
range.
|
||||||
"""
|
"""
|
||||||
ip = _normalise_ip(addr)
|
ips = _ips_to_judge(addr)
|
||||||
if ip is None:
|
if not ips:
|
||||||
return False
|
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):
|
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;
|
*proxy_urls* are the operator's configured proxies (yt-dlp's ``proxy`` option;
|
||||||
the ``*_proxy`` environment variables are picked up automatically). A proxy on
|
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.
|
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 costs proxied setups nothing: yt-dlp resolves the proxy itself at exactly that host:port,
|
||||||
that host:port, and a media URL is either handed to the proxy unresolved or
|
and a media URL is either handed to the proxy unresolved or resolved on its own
|
||||||
resolved on its own merits — never inheriting the proxy's allowance.
|
merits — never inheriting the proxy's allowance.
|
||||||
|
|
||||||
When *allow_private* is set (``ALLOW_PRIVATE_ADDRESSES``), the guard is not
|
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
|
installed at all, so proxy/VPN setups that route through private or Fake-IP
|
||||||
|
|||||||
Reference in New Issue
Block a user