diff --git a/app/tests/test_url_guard.py b/app/tests/test_url_guard.py index 0218b74..c2b4415 100644 --- a/app/tests/test_url_guard.py +++ b/app/tests/test_url_guard.py @@ -10,6 +10,7 @@ import url_guard from url_guard import ( validate_url, _address_allowed_at_connect, + _address_is_global, _guarded_getaddrinfo, _proxy_endpoint, install_socket_guard, @@ -138,6 +139,47 @@ class ConnectAddressPolicyTests(unittest.TestCase): 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): def test_explicit_port(self): self.assertEqual(_proxy_endpoint("http://127.0.0.1:9050"), ("127.0.0.1", 9050)) diff --git a/app/url_guard.py b/app/url_guard.py index 5823ec1..ed30908 100644 --- a/app/url_guard.py +++ b/app/url_guard.py @@ -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