fix: pin validated DNS for SSRF-safe fetches
maintainer edit: keep MCP HTTP SSRF checks strict, pin validated DNS for direct web_fetch and HTTP/SSE MCP requests, preserve explicit and environment proxy compatibility, and cover the proxy/redirect/rebinding cases with tests.
This commit is contained in:
@@ -9,6 +9,16 @@ import pytest
|
||||
|
||||
from nanobot.agent.tools.mcp import _probe_http_url, connect_mcp_servers
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.security.network import configure_ssrf_whitelist
|
||||
|
||||
_PROXY_ENV_VARS = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy")
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _probe_http_url unit tests
|
||||
@@ -23,9 +33,11 @@ async def test_probe_returns_true_for_open_port(tmp_path):
|
||||
|
||||
server = await asyncio.start_server(_close_connection, "127.0.0.1", 0)
|
||||
port = server.sockets[0].getsockname()[1]
|
||||
configure_ssrf_whitelist(["127.0.0.1/32"])
|
||||
try:
|
||||
assert await _probe_http_url(f"http://127.0.0.1:{port}/mcp") is True
|
||||
finally:
|
||||
configure_ssrf_whitelist([])
|
||||
server.close()
|
||||
await server.wait_closed()
|
||||
|
||||
@@ -51,6 +63,55 @@ async def test_probe_rejects_public_name_resolving_to_loopback():
|
||||
assert await _probe_http_url("http://example.com:8765/mcp") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_skips_direct_tcp_when_global_proxy_env_is_set(monkeypatch):
|
||||
def _resolver(hostname, port, family=0, type_=0):
|
||||
return [(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0))]
|
||||
|
||||
async def _open_connection(*args, **kwargs):
|
||||
raise AssertionError("global proxy env should skip direct TCP probe")
|
||||
|
||||
monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:8080")
|
||||
monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1")
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
|
||||
|
||||
with patch("nanobot.security.network.socket.getaddrinfo", _resolver):
|
||||
assert await _probe_http_url("https://mcp.example.com/mcp") is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_probe_tries_next_validated_ip_when_first_is_unreachable(monkeypatch):
|
||||
attempts: list[tuple[str, int]] = []
|
||||
|
||||
class FakeWriter:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
async def wait_closed(self):
|
||||
return None
|
||||
|
||||
def _resolver(hostname, port, family=0, type_=0):
|
||||
return [
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.34", 0)),
|
||||
(socket.AF_INET, socket.SOCK_STREAM, 0, "", ("93.184.216.35", 0)),
|
||||
]
|
||||
|
||||
async def _open_connection(host: str, port: int):
|
||||
attempts.append((host, port))
|
||||
if host == "93.184.216.34":
|
||||
raise OSError("first address unreachable")
|
||||
return object(), FakeWriter()
|
||||
|
||||
monkeypatch.setattr("nanobot.security.network.socket.getaddrinfo", _resolver)
|
||||
monkeypatch.setattr("nanobot.agent.tools.mcp.asyncio.open_connection", _open_connection)
|
||||
|
||||
assert await _probe_http_url("http://mcp.example:8765/mcp") is True
|
||||
assert attempts == [
|
||||
("93.184.216.34", 8765),
|
||||
("93.184.216.35", 8765),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# connect_mcp_servers skips unreachable HTTP servers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user