fix(url_safety): block IPv4-mapped IPv6 addresses to prevent SSRF bypass
This commit is contained in:
@@ -482,3 +482,70 @@ class TestIsAlwaysBlockedUrl:
|
|||||||
"""security.allow_private_urls can NOT unblock cloud metadata."""
|
"""security.allow_private_urls can NOT unblock cloud metadata."""
|
||||||
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "true")
|
||||||
assert is_always_blocked_url("http://169.254.169.254/") is True
|
assert is_always_blocked_url("http://169.254.169.254/") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestIPv4MappedIPv6SSRF:
|
||||||
|
"""Regression tests for SSRF bypass via IPv4-mapped IPv6 addresses.
|
||||||
|
|
||||||
|
DNS resolvers may return ``::ffff:x.x.x.x`` for IPv4-only hosts.
|
||||||
|
Python's ipaddress module treats these as distinct from the plain
|
||||||
|
IPv4 address, so ``ip in frozenset({IPv4Address(...)})`` and
|
||||||
|
``ip in IPv4Network(...)`` both return False. Without explicit
|
||||||
|
handling, an attacker could use IPv4-mapped addresses to bypass
|
||||||
|
all SSRF protections.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ── _is_blocked_ip direct tests ──
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ip_str", [
|
||||||
|
"::ffff:100.64.0.1", # CGNAT start
|
||||||
|
"::ffff:100.100.100.200", # Alibaba Cloud metadata (in CGNAT range)
|
||||||
|
"::ffff:100.127.255.254", # CGNAT end
|
||||||
|
"::ffff:169.254.42.99", # Link-local (non-metadata)
|
||||||
|
"::ffff:0.0.0.0", # Unspecified
|
||||||
|
"::ffff:224.0.0.1", # Multicast
|
||||||
|
])
|
||||||
|
def test_ipv4_mapped_blocked_ips(self, ip_str):
|
||||||
|
"""IPv4-mapped IPv6 addresses that should be blocked."""
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
assert _is_blocked_ip(ip) is True, f"{ip_str} should be blocked"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("ip_str", [
|
||||||
|
"::ffff:8.8.8.8", # Public DNS
|
||||||
|
"::ffff:93.184.216.34", # example.com
|
||||||
|
"::ffff:100.0.0.1", # Not in CGNAT range
|
||||||
|
])
|
||||||
|
def test_ipv4_mapped_allowed_ips(self, ip_str):
|
||||||
|
"""IPv4-mapped IPv6 addresses that should be allowed."""
|
||||||
|
ip = ipaddress.ip_address(ip_str)
|
||||||
|
assert _is_blocked_ip(ip) is False, f"{ip_str} should be allowed"
|
||||||
|
|
||||||
|
# ── is_safe_url integration tests: always-blocked metadata IPs ──
|
||||||
|
|
||||||
|
def test_ipv4_mapped_aws_metadata_blocked(self):
|
||||||
|
"""::ffff:169.254.169.254 (AWS metadata) must always be blocked."""
|
||||||
|
with patch("socket.getaddrinfo", return_value=[
|
||||||
|
(10, 1, 6, "", ("::ffff:169.254.169.254", 0, 0, 0)),
|
||||||
|
]):
|
||||||
|
assert is_safe_url("http://aws-metadata.internal/") is False
|
||||||
|
|
||||||
|
def test_ipv4_mapped_ecs_metadata_blocked(self):
|
||||||
|
"""::ffff:169.254.170.2 (AWS ECS task metadata) must always be blocked."""
|
||||||
|
with patch("socket.getaddrinfo", return_value=[
|
||||||
|
(10, 1, 6, "", ("::ffff:169.254.170.2", 0, 0, 0)),
|
||||||
|
]):
|
||||||
|
assert is_safe_url("http://ecs-metadata.internal/") is False
|
||||||
|
|
||||||
|
def test_ipv4_mapped_azure_wire_server_blocked(self):
|
||||||
|
"""::ffff:169.254.169.253 (Azure IMDS wire server) must always be blocked."""
|
||||||
|
with patch("socket.getaddrinfo", return_value=[
|
||||||
|
(10, 1, 6, "", ("::ffff:169.254.169.253", 0, 0, 0)),
|
||||||
|
]):
|
||||||
|
assert is_safe_url("http://azure-metadata.internal/") is False
|
||||||
|
|
||||||
|
def test_ipv4_mapped_alibaba_metadata_blocked(self):
|
||||||
|
"""::ffff:100.100.100.200 (Alibaba Cloud metadata) must always be blocked."""
|
||||||
|
with patch("socket.getaddrinfo", return_value=[
|
||||||
|
(10, 1, 6, "", ("::ffff:100.100.100.200", 0, 0, 0)),
|
||||||
|
]):
|
||||||
|
assert is_safe_url("http://aliyun-metadata.internal/") is False
|
||||||
|
|||||||
@@ -45,15 +45,26 @@ _BLOCKED_HOSTNAMES = frozenset({
|
|||||||
# allow_private_urls toggle. These are cloud metadata / credential
|
# allow_private_urls toggle. These are cloud metadata / credential
|
||||||
# endpoints — the #1 SSRF target — and the link-local range where
|
# endpoints — the #1 SSRF target — and the link-local range where
|
||||||
# they all live.
|
# they all live.
|
||||||
|
#
|
||||||
|
# IPv4-mapped IPv6 variants are included because DNS resolvers may
|
||||||
|
# return ``::ffff:x.x.x.x`` for IPv4-only hosts, and Python's
|
||||||
|
# ipaddress module treats these as distinct from the plain IPv4
|
||||||
|
# address (they won't match ``ip in frozenset`` or ``ip in network``).
|
||||||
_ALWAYS_BLOCKED_IPS = frozenset({
|
_ALWAYS_BLOCKED_IPS = frozenset({
|
||||||
ipaddress.ip_address("169.254.169.254"), # AWS/GCP/Azure/DO/Oracle metadata
|
ipaddress.ip_address("169.254.169.254"), # AWS/GCP/Azure/DO/Oracle metadata
|
||||||
ipaddress.ip_address("169.254.170.2"), # AWS ECS task metadata (task IAM creds)
|
ipaddress.ip_address("169.254.170.2"), # AWS ECS task metadata (task IAM creds)
|
||||||
ipaddress.ip_address("169.254.169.253"), # Azure IMDS wire server
|
ipaddress.ip_address("169.254.169.253"), # Azure IMDS wire server
|
||||||
ipaddress.ip_address("fd00:ec2::254"), # AWS metadata (IPv6)
|
ipaddress.ip_address("fd00:ec2::254"), # AWS metadata (IPv6)
|
||||||
ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud metadata
|
ipaddress.ip_address("100.100.100.200"), # Alibaba Cloud metadata
|
||||||
|
# IPv4-mapped IPv6 variants — same endpoints reachable via ::ffff:x.x.x.x
|
||||||
|
ipaddress.ip_address("::ffff:169.254.169.254"),
|
||||||
|
ipaddress.ip_address("::ffff:169.254.170.2"),
|
||||||
|
ipaddress.ip_address("::ffff:169.254.169.253"),
|
||||||
|
ipaddress.ip_address("::ffff:100.100.100.200"),
|
||||||
})
|
})
|
||||||
_ALWAYS_BLOCKED_NETWORKS = (
|
_ALWAYS_BLOCKED_NETWORKS = (
|
||||||
ipaddress.ip_network("169.254.0.0/16"), # Entire link-local range (no legit agent target)
|
ipaddress.ip_network("169.254.0.0/16"), # Entire link-local range (no legit agent target)
|
||||||
|
ipaddress.ip_network("::ffff:169.254.0.0/112"), # IPv4-mapped link-local range
|
||||||
)
|
)
|
||||||
|
|
||||||
# Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs.
|
# Exact HTTPS hostnames allowed to resolve to private/benchmark-space IPs.
|
||||||
@@ -137,6 +148,16 @@ def _reset_allow_private_cache() -> None:
|
|||||||
|
|
||||||
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
"""Return True if the IP should be blocked for SSRF protection."""
|
"""Return True if the IP should be blocked for SSRF protection."""
|
||||||
|
# IPv4-mapped IPv6 addresses (``::ffff:x.x.x.x``) should be checked
|
||||||
|
# by their embedded IPv4 address, not as IPv6
|
||||||
|
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
||||||
|
embedded_ip = ip.ipv4_mapped
|
||||||
|
return (embedded_ip.is_private or embedded_ip.is_loopback or
|
||||||
|
embedded_ip.is_link_local or embedded_ip.is_reserved or
|
||||||
|
embedded_ip.is_multicast or embedded_ip.is_unspecified or
|
||||||
|
embedded_ip in _CGNAT_NETWORK)
|
||||||
|
|
||||||
|
# Standard IPv4/IPv6 address checking
|
||||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||||
return True
|
return True
|
||||||
if ip.is_multicast or ip.is_unspecified:
|
if ip.is_multicast or ip.is_unspecified:
|
||||||
|
|||||||
Reference in New Issue
Block a user