mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 05:14:12 +00:00
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:
co-authored by
Claude Opus 4.8
parent
1f0ec48448
commit
08ed8464b8
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user