mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-14 14:54:06 +00:00
fix(security): normalize IPv6-mapped IPv4 addresses in SSRF checks
::ffff:127.0.0.1 and ::ffff:169.254.169.254 are IPv6Address objects that match neither the IPv4 blocklists (127.0.0.0/8, 169.254.0.0/16) nor the IPv6 ones (::1/128), allowing SSRF bypass via DNS responses that return IPv6-mapped IPv4 addresses. Add _normalize_addr() to convert ipv4_mapped IPv6 addresses to their IPv4 form before blocklist/allowlist matching.
This commit is contained in:
parent
1d4000560d
commit
13dec9d2c2
@ -36,10 +36,26 @@ def configure_ssrf_whitelist(cidrs: list[str]) -> None:
|
|||||||
_allowed_networks = nets
|
_allowed_networks = nets
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_addr(
|
||||||
|
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||||
|
) -> ipaddress.IPv4Address | ipaddress.IPv6Address:
|
||||||
|
"""Normalize IPv6-mapped IPv4 addresses to their IPv4 form.
|
||||||
|
|
||||||
|
``::ffff:127.0.0.1`` is semantically identical to ``127.0.0.1`` but
|
||||||
|
Python's ipaddress treats it as an IPv6Address that matches neither
|
||||||
|
``127.0.0.0/8`` nor ``::1/128``. Converting it to IPv4 ensures
|
||||||
|
blocklist/allowlist checks work correctly.
|
||||||
|
"""
|
||||||
|
if isinstance(addr, ipaddress.IPv6Address) and addr.ipv4_mapped is not None:
|
||||||
|
return addr.ipv4_mapped
|
||||||
|
return addr
|
||||||
|
|
||||||
|
|
||||||
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
def _is_private(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
if _allowed_networks and any(addr in net for net in _allowed_networks):
|
normalized = _normalize_addr(addr)
|
||||||
|
if _allowed_networks and any(normalized in net for net in _allowed_networks):
|
||||||
return False
|
return False
|
||||||
return any(addr in net for net in _BLOCKED_NETWORKS)
|
return any(normalized in net for net in _BLOCKED_NETWORKS)
|
||||||
|
|
||||||
|
|
||||||
def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
|
def validate_url_target(url: str, *, allow_loopback: bool = False) -> tuple[bool, str]:
|
||||||
|
|||||||
@ -62,6 +62,54 @@ def test_blocks_ipv6_loopback():
|
|||||||
assert not ok
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# validate_url_target — IPv6-mapped IPv4 bypass prevention
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fake_resolve_v6(host: str, results: list[str]):
|
||||||
|
"""Like _fake_resolve but returns AF_INET6 tuples for IPv6 addresses."""
|
||||||
|
def _resolver(hostname, port, family=0, type_=0):
|
||||||
|
if hostname == host:
|
||||||
|
entries = []
|
||||||
|
for ip in results:
|
||||||
|
if ":" in ip:
|
||||||
|
entries.append((socket.AF_INET6, socket.SOCK_STREAM, 0, "", (ip, 0, 0, 0)))
|
||||||
|
else:
|
||||||
|
entries.append((socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0)))
|
||||||
|
return entries
|
||||||
|
raise socket.gaierror(f"cannot resolve {hostname}")
|
||||||
|
return _resolver
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocks_ipv6_mapped_loopback():
|
||||||
|
"""::ffff:127.0.0.1 must be blocked just like 127.0.0.1."""
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("evil.com", ["::ffff:127.0.0.1"])):
|
||||||
|
ok, err = validate_url_target("http://evil.com/")
|
||||||
|
assert not ok
|
||||||
|
assert "blocked" in err.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocks_ipv6_mapped_metadata():
|
||||||
|
"""::ffff:169.254.169.254 must be blocked just like 169.254.169.254."""
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("evil.com", ["::ffff:169.254.169.254"])):
|
||||||
|
ok, err = validate_url_target("http://evil.com/")
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_blocks_ipv6_mapped_rfc1918():
|
||||||
|
"""::ffff:10.0.0.1 must be blocked just like 10.0.0.1."""
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("evil.com", ["::ffff:10.0.0.1"])):
|
||||||
|
ok, err = validate_url_target("http://evil.com/")
|
||||||
|
assert not ok
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_public_ipv6():
|
||||||
|
"""Public IPv6 addresses must still be allowed."""
|
||||||
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("example.com", ["2606:4700::6810:84e5"])):
|
||||||
|
ok, err = validate_url_target("http://example.com/")
|
||||||
|
assert ok, f"Should allow public IPv6, got: {err}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# validate_url_target — allows public IPs
|
# validate_url_target — allows public IPs
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user