mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
445 lines
16 KiB
Python
445 lines
16 KiB
Python
"""Tests for nanobot.security.network — SSRF protection and internal URL detection."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import socket
|
|
import threading
|
|
import time
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from nanobot.security.network import (
|
|
async_resolve_url_target,
|
|
async_validate_resolved_url,
|
|
configure_ssrf_whitelist,
|
|
contains_internal_url,
|
|
env_proxy_applies_to_url,
|
|
httpx_env_proxy_mounts,
|
|
is_loopback_host,
|
|
pin_resolved_url_dns,
|
|
resolve_url_target,
|
|
validate_url_target,
|
|
)
|
|
|
|
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
["localhost", "LOCALHOST.", "127.0.0.1", "127.0.0.2", "::1", "[::1]"],
|
|
)
|
|
def test_is_loopback_host_accepts_explicit_loopback(host: str) -> None:
|
|
assert is_loopback_host(host)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"host",
|
|
["0.0.0.0", "::", "192.168.1.10", "api.internal", "example.com"],
|
|
)
|
|
def test_is_loopback_host_rejects_network_targets(host: str) -> None:
|
|
assert not is_loopback_host(host)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_proxy_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for name in (*_PROXY_ENV_VARS, "NO_PROXY", "no_proxy"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
def _fake_resolve(host: str, results: list[str]):
|
|
"""Return a getaddrinfo mock that maps the given host to fake IP results."""
|
|
def _resolver(hostname, port, family=0, type_=0):
|
|
if hostname == host:
|
|
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", (ip, 0)) for ip in results]
|
|
raise socket.gaierror(f"cannot resolve {hostname}")
|
|
return _resolver
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_url_target — scheme / domain basics
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_rejects_non_http_scheme():
|
|
ok, err = validate_url_target("ftp://example.com/file")
|
|
assert not ok
|
|
assert "http" in err.lower()
|
|
|
|
|
|
def test_rejects_missing_domain():
|
|
ok, err = validate_url_target("http://")
|
|
assert not ok
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# validate_url_target — blocked private/internal IPs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@pytest.mark.parametrize("ip,label", [
|
|
("127.0.0.1", "loopback"),
|
|
("127.0.0.2", "loopback_alt"),
|
|
("10.0.0.1", "rfc1918_10"),
|
|
("172.16.5.1", "rfc1918_172"),
|
|
("192.168.1.1", "rfc1918_192"),
|
|
("169.254.169.254", "metadata"),
|
|
("0.0.0.0", "zero"),
|
|
])
|
|
def test_blocks_private_ipv4(ip: str, label: str):
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", [ip])):
|
|
ok, err = validate_url_target("http://evil.com/path")
|
|
assert not ok, f"Should block {label} ({ip})"
|
|
assert "private" in err.lower() or "blocked" in err.lower()
|
|
|
|
|
|
def test_blocks_ipv6_loopback():
|
|
def _resolver(hostname, port, family=0, type_=0):
|
|
return [(socket.AF_INET6, socket.SOCK_STREAM, 0, "", ("::1", 0, 0, 0))]
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _resolver):
|
|
ok, err = validate_url_target("http://evil.com/")
|
|
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_blocks_sampled_addresses_from_internal_networks():
|
|
"""Property-style guard: sampled blocked CIDRs must all fail closed."""
|
|
configure_ssrf_whitelist([])
|
|
blocked_networks = [
|
|
"0.0.0.0/8",
|
|
"10.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"127.0.0.0/8",
|
|
"169.254.0.0/16",
|
|
"172.16.0.0/12",
|
|
"192.168.0.0/16",
|
|
"::/128",
|
|
"::1/128",
|
|
"fc00::/7",
|
|
"fe80::/10",
|
|
]
|
|
samples: list[str] = []
|
|
for cidr in blocked_networks:
|
|
network = ipaddress.ip_network(cidr)
|
|
samples.append(str(network.network_address))
|
|
if network.num_addresses > 2:
|
|
samples.append(str(network.network_address + 1))
|
|
samples.append(str(network[-2]))
|
|
|
|
for idx, ip in enumerate(samples):
|
|
host = f"internal-{idx}.example"
|
|
resolver = _fake_resolve_v6 if ":" in ip else _fake_resolve
|
|
with patch("nanobot.security.network.socket.getaddrinfo", resolver(host, [ip])):
|
|
ok, err = validate_url_target(f"http://{host}/")
|
|
assert not ok, f"expected {ip} to be blocked"
|
|
assert "blocked" in err.lower() or "private" in err.lower()
|
|
|
|
|
|
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
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_allows_public_ip():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])):
|
|
ok, err = validate_url_target("http://example.com/page")
|
|
assert ok, f"Should allow public IP, got: {err}"
|
|
|
|
|
|
def test_resolve_url_target_returns_validated_public_ips():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])):
|
|
ok, err, resolved_ips = resolve_url_target("http://example.com/page")
|
|
|
|
assert ok, err
|
|
assert resolved_ips == ("93.184.216.34",)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("trust_remote_dns", "expected_ok"),
|
|
[(False, False), (True, True)],
|
|
)
|
|
def test_resolve_url_target_only_delegates_dns_to_trusted_proxy(
|
|
trust_remote_dns: bool,
|
|
expected_ok: bool,
|
|
):
|
|
with patch(
|
|
"nanobot.security.network.socket.getaddrinfo",
|
|
side_effect=socket.gaierror("local DNS unavailable"),
|
|
):
|
|
ok, err, resolved_ips = resolve_url_target(
|
|
"https://proxy-only.example/image.png",
|
|
trust_remote_dns=trust_remote_dns,
|
|
)
|
|
|
|
assert ok is expected_ok, err
|
|
assert resolved_ips == ()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url",
|
|
[
|
|
"http://localhost/secret",
|
|
"http://service.localhost/secret",
|
|
"http://127.0.0.1/secret",
|
|
"http://169.254.169.254/latest",
|
|
"http://[::1]/secret",
|
|
],
|
|
)
|
|
def test_resolve_url_target_does_not_delegate_local_targets(url: str):
|
|
with patch(
|
|
"nanobot.security.network.socket.getaddrinfo",
|
|
side_effect=socket.gaierror("local DNS unavailable"),
|
|
):
|
|
ok, _, _ = resolve_url_target(url, trust_remote_dns=True)
|
|
|
|
assert not ok
|
|
|
|
|
|
def test_pin_resolved_url_dns_prevents_second_resolution_rebind():
|
|
def _rebinding_resolver(hostname, port, family=0, type_=0):
|
|
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
|
|
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _rebinding_resolver):
|
|
with pin_resolved_url_dns("http://example.com/page", ("93.184.216.34",)):
|
|
infos = socket.getaddrinfo("example.com", 80, socket.AF_UNSPEC, socket.SOCK_STREAM)
|
|
|
|
assert infos[0][4][0] == "93.184.216.34"
|
|
|
|
|
|
def test_allows_normal_https():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("github.com", ["140.82.121.3"])):
|
|
ok, err = validate_url_target("https://github.com/HKUDS/nanobot")
|
|
assert ok
|
|
|
|
|
|
def test_env_proxy_helpers_respect_no_proxy(monkeypatch):
|
|
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
|
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
|
|
|
assert env_proxy_applies_to_url("https://example.com/page")
|
|
assert not env_proxy_applies_to_url("http://localhost:8765/mcp")
|
|
|
|
mounts = httpx_env_proxy_mounts()
|
|
assert any(transport is None for transport in mounts.values())
|
|
assert any(transport is not None for transport in mounts.values())
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# contains_internal_url — shell command scanning
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_detects_curl_metadata():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("169.254.169.254", ["169.254.169.254"])):
|
|
assert contains_internal_url('curl -s http://169.254.169.254/computeMetadata/v1/')
|
|
|
|
|
|
def test_detects_wget_localhost():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("localhost", ["127.0.0.1"])):
|
|
assert contains_internal_url("wget http://localhost:8080/secret")
|
|
|
|
|
|
def test_loopback_exception_allows_literal_localhost_only():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("localhost", ["127.0.0.1"])):
|
|
assert not contains_internal_url("curl http://localhost:8765/", allow_loopback=True)
|
|
|
|
|
|
def test_loopback_exception_rejects_public_name_resolving_to_loopback():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["127.0.0.1"])):
|
|
assert contains_internal_url("curl http://example.com:8765/", allow_loopback=True)
|
|
|
|
|
|
def test_loopback_exception_rejects_metadata():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("169.254.169.254", ["169.254.169.254"])):
|
|
assert contains_internal_url("curl http://169.254.169.254/latest/meta-data/", allow_loopback=True)
|
|
|
|
|
|
def test_detects_ipv6_mapped_loopback():
|
|
"""contains_internal_url must catch IPv6-mapped loopback in shell commands."""
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("evil.com", ["::ffff:127.0.0.1"])):
|
|
assert contains_internal_url("curl http://evil.com/secret")
|
|
|
|
|
|
def test_allows_normal_curl():
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("example.com", ["93.184.216.34"])):
|
|
assert not contains_internal_url("curl https://example.com/api/data")
|
|
|
|
|
|
def test_no_urls_returns_false():
|
|
assert not contains_internal_url("echo hello && ls -la")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SSRF whitelist — allow specific CIDR ranges (#2669)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_blocks_cgnat_by_default():
|
|
"""100.64.0.0/10 (CGNAT / Tailscale) is blocked by default."""
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
|
ok, _ = validate_url_target("http://ts.local/api")
|
|
assert not ok
|
|
|
|
|
|
def test_whitelist_allows_cgnat():
|
|
"""Whitelisting 100.64.0.0/10 lets Tailscale addresses through."""
|
|
configure_ssrf_whitelist(["100.64.0.0/10"])
|
|
try:
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
|
ok, err = validate_url_target("http://ts.local/api")
|
|
assert ok, f"Whitelisted CGNAT should be allowed, got: {err}"
|
|
finally:
|
|
configure_ssrf_whitelist([])
|
|
|
|
|
|
def test_whitelist_does_not_affect_other_blocked():
|
|
"""Whitelisting CGNAT must not unblock other private ranges."""
|
|
configure_ssrf_whitelist(["100.64.0.0/10"])
|
|
try:
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("evil.com", ["10.0.0.1"])):
|
|
ok, _ = validate_url_target("http://evil.com/secret")
|
|
assert not ok
|
|
finally:
|
|
configure_ssrf_whitelist([])
|
|
|
|
|
|
def test_whitelist_invalid_cidr_ignored():
|
|
"""Invalid CIDR entries are silently skipped."""
|
|
configure_ssrf_whitelist(["not-a-cidr", "100.64.0.0/10"])
|
|
try:
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve("ts.local", ["100.100.1.1"])):
|
|
ok, _ = validate_url_target("http://ts.local/api")
|
|
assert ok
|
|
finally:
|
|
configure_ssrf_whitelist([])
|
|
|
|
|
|
def test_whitelist_allows_ipv6_mapped_cgnat():
|
|
"""Whitelist must work when DNS returns IPv6-mapped CGNAT address."""
|
|
configure_ssrf_whitelist(["100.64.0.0/10"])
|
|
try:
|
|
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_v6("ts.local", ["::ffff:100.100.1.1"])):
|
|
ok, err = validate_url_target("http://ts.local/api")
|
|
assert ok, f"Whitelisted IPv6-mapped CGNAT should be allowed, got: {err}"
|
|
finally:
|
|
configure_ssrf_whitelist([])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_dns_resolution_keeps_event_loop_responsive(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
started = threading.Event()
|
|
|
|
def slow_resolver(hostname, port, family=0, type=0, proto=0, flags=0): # noqa: A002
|
|
started.set()
|
|
time.sleep(0.08)
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0)),
|
|
]
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", slow_resolver)
|
|
task = asyncio.create_task(async_resolve_url_target("https://example.com/path"))
|
|
assert await asyncio.to_thread(started.wait, 0.5)
|
|
for _ in range(3):
|
|
await asyncio.sleep(0.01)
|
|
assert not task.done()
|
|
|
|
assert await asyncio.wait_for(task, timeout=0.5) == (
|
|
True,
|
|
"",
|
|
("93.184.216.34",),
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_dns_validation_preserves_sync_security_results(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
def resolver(hostname, port, family=0, type=0, proto=0, flags=0): # noqa: A002
|
|
assert hostname == "evil.com"
|
|
return [
|
|
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0)),
|
|
]
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", resolver)
|
|
|
|
sync_result = resolve_url_target("http://evil.com/latest")
|
|
async_result = await async_resolve_url_target("http://evil.com/latest")
|
|
redirect_result = await async_validate_resolved_url("http://evil.com/latest")
|
|
|
|
assert async_result == sync_result
|
|
assert async_result[0] is False
|
|
assert redirect_result[0] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_dns_resolution_times_out(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
loop = asyncio.get_running_loop()
|
|
never_resolves = asyncio.Event()
|
|
|
|
async def stalled_getaddrinfo(*args, **kwargs):
|
|
await never_resolves.wait()
|
|
|
|
monkeypatch.setattr(loop, "getaddrinfo", stalled_getaddrinfo)
|
|
|
|
result = await asyncio.wait_for(
|
|
async_resolve_url_target(
|
|
"https://example.com/path",
|
|
timeout_s=0.01,
|
|
),
|
|
timeout=0.2,
|
|
)
|
|
|
|
assert result == (
|
|
False,
|
|
"Timed out resolving hostname: example.com",
|
|
(),
|
|
)
|