diff --git a/nanobot/security/network.py b/nanobot/security/network.py index dfd3e9e47..6abc559b7 100644 --- a/nanobot/security/network.py +++ b/nanobot/security/network.py @@ -36,10 +36,26 @@ def configure_ssrf_whitelist(cidrs: list[str]) -> None: _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: - 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 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]: diff --git a/tests/security/test_security_network.py b/tests/security/test_security_network.py index de4b90d3f..88a719ab5 100644 --- a/tests/security/test_security_network.py +++ b/tests/security/test_security_network.py @@ -62,6 +62,54 @@ def test_blocks_ipv6_loopback(): 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 # ---------------------------------------------------------------------------