Merge remote-tracking branch 'origin/main'
# Conflicts: # nanobot/agent/tools/web.py
This commit is contained in:
69
tests/test_exec_security.py
Normal file
69
tests/test_exec_security.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for exec tool internal URL blocking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.shell import ExecTool
|
||||
|
||||
|
||||
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
|
||||
|
||||
|
||||
def _fake_resolve_localhost(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]
|
||||
|
||||
|
||||
def _fake_resolve_public(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_blocks_curl_metadata():
|
||||
tool = ExecTool()
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
|
||||
result = await tool.execute(
|
||||
command='curl -s -H "Metadata-Flavor: Google" http://169.254.169.254/computeMetadata/v1/'
|
||||
)
|
||||
assert "Error" in result
|
||||
assert "internal" in result.lower() or "private" in result.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_blocks_wget_localhost():
|
||||
tool = ExecTool()
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_localhost):
|
||||
result = await tool.execute(command="wget http://localhost:8080/secret -O /tmp/out")
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_allows_normal_commands():
|
||||
tool = ExecTool(timeout=5)
|
||||
result = await tool.execute(command="echo hello")
|
||||
assert "hello" in result
|
||||
assert "Error" not in result.split("\n")[0]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_allows_curl_to_public_url():
|
||||
"""Commands with public URLs should not be blocked by the internal URL check."""
|
||||
tool = ExecTool()
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public):
|
||||
guard_result = tool._guard_command("curl https://example.com/api", "/tmp")
|
||||
assert guard_result is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exec_blocks_chained_internal_url():
|
||||
"""Internal URLs buried in chained commands should still be caught."""
|
||||
tool = ExecTool()
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
|
||||
result = await tool.execute(
|
||||
command="echo start && curl http://169.254.169.254/latest/meta-data/ && echo done"
|
||||
)
|
||||
assert "Error" in result
|
||||
101
tests/test_security_network.py
Normal file
101
tests/test_security_network.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Tests for nanobot.security.network — SSRF protection and internal URL detection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.security.network import contains_internal_url, validate_url_target
|
||||
|
||||
|
||||
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(f"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 — 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_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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_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")
|
||||
69
tests/test_web_fetch_security.py
Normal file
69
tests/test_web_fetch_security.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Tests for web_fetch SSRF protection and untrusted content marking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.tools.web import WebFetchTool
|
||||
|
||||
|
||||
def _fake_resolve_private(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("169.254.169.254", 0))]
|
||||
|
||||
|
||||
def _fake_resolve_public(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_blocks_private_ip():
|
||||
tool = WebFetchTool()
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_private):
|
||||
result = await tool.execute(url="http://169.254.169.254/computeMetadata/v1/")
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
assert "private" in data["error"].lower() or "blocked" in data["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_blocks_localhost():
|
||||
tool = WebFetchTool()
|
||||
def _resolve_localhost(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("127.0.0.1", 0))]
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _resolve_localhost):
|
||||
result = await tool.execute(url="http://localhost/admin")
|
||||
data = json.loads(result)
|
||||
assert "error" in data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_web_fetch_result_contains_untrusted_flag():
|
||||
"""When fetch succeeds, result JSON must include untrusted=True and the banner."""
|
||||
tool = WebFetchTool()
|
||||
|
||||
fake_html = "<html><head><title>Test</title></head><body><p>Hello world</p></body></html>"
|
||||
|
||||
import httpx
|
||||
|
||||
class FakeResponse:
|
||||
status_code = 200
|
||||
url = "https://example.com/page"
|
||||
text = fake_html
|
||||
headers = {"content-type": "text/html"}
|
||||
def raise_for_status(self): pass
|
||||
def json(self): return {}
|
||||
|
||||
async def _fake_get(self, url, **kwargs):
|
||||
return FakeResponse()
|
||||
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _fake_resolve_public), \
|
||||
patch("httpx.AsyncClient.get", _fake_get):
|
||||
result = await tool.execute(url="https://example.com/page")
|
||||
|
||||
data = json.loads(result)
|
||||
assert data.get("untrusted") is True
|
||||
assert "[External content" in data.get("text", "")
|
||||
Reference in New Issue
Block a user