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:
chengyongru
2026-07-07 15:40:53 +08:00
committed by Xubin Ren
parent b68ae4f9bc
commit c5e053f83b
8 changed files with 436 additions and 34 deletions
+61
View File
@@ -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
# ---------------------------------------------------------------------------