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
+42
View File
@@ -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))