fix(security): close IPv4-mapped IPv6 metadata SSRF bypass and mypy types

_check_address now canonicalizes IPv4-mapped IPv6 addresses (e.g.
::ffff:169.254.169.254) to their embedded IPv4 target before the metadata
and non-unicast checks. Previously the mapped form was a distinct address
object absent from _METADATA_ADDRESSES with is_reserved=False/is_global=False,
so under allow_private=True it slipped past every check and the socket layer
still dialed the mapped IPv4 metadata endpoint. test_allow_private_does_not_
allow_metadata is parametrized over the plain and mapped spellings.

Also cast SafeAiohttpResolver.resolve's ResolveResult host/port to str/int to
satisfy aiohttp's TypedDict (fixes the two mypy errors in the strict-overrides
CI gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agessaman
2026-07-22 14:15:51 -07:00
co-authored by Claude Opus 4.8
parent 1f0ec48448
commit 08ed8464b8
2 changed files with 25 additions and 4 deletions
+9 -2
View File
@@ -124,6 +124,13 @@ class SafeUrlPolicy:
return scheme, hostname, port or (443 if scheme == "https" else 80)
def _check_address(self, address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> None:
# Canonicalize IPv4-mapped IPv6 (e.g. ``::ffff:169.254.169.254``) to the
# IPv4 target the socket layer will actually dial. The mapped form is a
# distinct address object that is absent from _METADATA_ADDRESSES and
# reports is_reserved=False/is_global=False, so without this it would
# bypass the metadata and non-unicast checks under allow_private=True.
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped is not None:
address = address.ipv4_mapped
if address in _METADATA_ADDRESSES:
raise UnsafeUrlError(f"Cloud metadata address is not allowed: {address}")
# These cannot be meaningful unicast HTTP server destinations. They
@@ -240,8 +247,8 @@ class SafeAiohttpResolver(AbstractResolver):
return [
{
"hostname": host,
"host": record[4][0],
"port": record[4][1],
"host": str(record[4][0]),
"port": int(record[4][1]),
"family": record[0],
"proto": record[2],
"flags": socket.AI_NUMERICHOST,
+16 -2
View File
@@ -178,8 +178,22 @@ class TestSafeUrlPolicy:
):
assert validate_external_url("https://mixed.example/") is False
def test_allow_private_does_not_allow_metadata(self):
with patch("socket.getaddrinfo", return_value=_addrinfo("169.254.169.254")):
@pytest.mark.parametrize(
"resolved",
[
"169.254.169.254", # AWS/Azure/GCP IMDS
"169.254.170.2", # AWS ECS task credentials
"100.100.100.200", # Alibaba Cloud
# IPv4-mapped IPv6 spellings must not bypass the metadata block:
# the mapped form is a distinct address object absent from the
# metadata set and reports is_reserved=False.
"::ffff:169.254.169.254",
"::ffff:169.254.170.2",
"::ffff:100.100.100.200",
],
)
def test_allow_private_does_not_allow_metadata(self, resolved):
with patch("socket.getaddrinfo", return_value=_addrinfo(resolved)):
assert validate_external_url(
"http://metadata.example/",
allow_private=True,