deer-flow/backend/tests/blocking_io/test_web_tool_url_validation.py
Hyeonsang Cho bb4f7ac5e2
fix(browser): pin browser connections to the SSRF-vetted addresses (#6201)
* fix(browser): pin browser connections to the SSRF-vetted addresses

The navigate screen and the per-request guard resolve a hostname to vet
it, but Chromium resolved the name again to open the connection. A
rebinding DNS server could answer both checks with a public address and
the connection with a private or cloud-metadata one, and the browser
followed it.

Launch each private browser through a per-session loopback SOCKS5 proxy.
Over SOCKS5 Chromium sends the hostname itself, so the proxy resolves it
exactly once through the session's egress resolver and connects only to
the addresses that resolver vetted. resolve_browser_egress applies the
same allow_private_addresses policy as validate_browser_url, on top of a
new url_safety.resolve_public_addresses helper that
validate_public_http_url now shares. Loopback is proxied explicitly with
bypass "<-loopback>". The tools, navigate_and_capture and the Live
WebSocket all pass the resolver, because a thread's first caller fixes
its launch options.

CDP-attached Chrome is unchanged. Delegated fetch services (crawl4ai,
Browserless, fastcrw) still resolve on their own side, which the Gateway
cannot pin.

* docs(changelog): reference #6201 in the browser egress pinning entry

* fix(browser): bound egress proxy faults and close, and record the WebRTC gap

Review follow-ups for the pinned browser egress proxy:

- A resolver exception other than ValueError escaped the connection
  handler as an unretrieved task error. Log it with its traceback and
  reply SOCKS5 general failure (0x01), so a resolver fault stays
  distinguishable from a policy refusal (0x02).
- Bound the resolve with its own timeout. The handshake timeout did not
  cover it, so a wedged getaddrinfo held the half-open tunnel until
  Chromium gave up. A timeout replies host unreachable.
- Bound close(): Server.wait_closed() also waits for a connection whose
  handler registered after the writer sweep, which could stall session
  close and eviction for the 15 s handshake. A handler that starts after
  close now drops its connection without opening a tunnel.
- Docs said a launched browser connects "solely" through the proxy.
  WebRTC UDP does not traverse a SOCKS proxy, so AGENTS.md, README (en/zh)
  and both changelogs now scope the guarantee to TCP and name the gap.
2026-10-03 10:05:47 +08:00

140 lines
5.6 KiB
Python

"""Web tools must resolve SSRF-screened hostnames off the agent event loop.
``http://127.1/`` is not an ``ipaddress`` literal, so the URL guard hands it to
the real ``socket.getaddrinfo``, which expands it to 127.0.0.1 without any DNS
traffic. The strict gate's ``socket.getaddrinfo`` rule fails each test if the
tool resolves on the loop instead of in a worker thread.
"""
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from deerflow.community.browser_automation import tools as browser_tools
from deerflow.community.browser_automation.egress import BrowserEgressProxy
from deerflow.community.browserless import tools as browserless_tools
from deerflow.community.crawl4ai import tools as crawl4ai_tools
pytestmark = pytest.mark.asyncio
_UNRESOLVED_LOOPBACK_URL = "http://127.1/"
async def test_crawl4ai_web_fetch_resolves_off_loop() -> None:
with (
patch.object(crawl4ai_tools, "_get_tool_config", return_value={}),
patch.object(crawl4ai_tools, "_build_client") as build_client,
):
result = await crawl4ai_tools.web_fetch_tool.ainvoke({"url": _UNRESOLVED_LOOPBACK_URL})
assert result == "Error: Refusing to fetch a private, loopback, or metadata address"
build_client.assert_not_called()
async def test_browserless_web_fetch_resolves_off_loop() -> None:
with (
patch.object(browserless_tools, "_get_tool_config", return_value={}),
patch.object(browserless_tools, "_get_browserless_client") as get_client,
):
result = await browserless_tools.web_fetch_tool.ainvoke({"url": _UNRESOLVED_LOOPBACK_URL})
assert result == "Error: Refusing to fetch a private, loopback, or metadata address"
get_client.assert_not_called()
async def test_browserless_web_capture_resolves_off_loop() -> None:
with (
patch.object(browserless_tools, "_get_tool_config", return_value={}),
patch.object(browserless_tools, "_get_browserless_client") as get_client,
):
result = await browserless_tools.web_capture_tool.coroutine(
runtime=SimpleNamespace(context={}, state={}),
url=_UNRESOLVED_LOOPBACK_URL,
tool_call_id="call-1",
)
assert result.update["messages"][0].content == "Error: Refusing to capture a private, loopback, or metadata address"
get_client.assert_not_called()
async def test_browser_navigate_tool_resolves_off_loop() -> None:
manager = MagicMock()
with (
patch.object(browser_tools, "_get_tool_config", return_value={}),
patch.object(browser_tools, "get_browser_session_manager", return_value=manager),
):
result = await browser_tools.browser_navigate_tool.coroutine(
runtime=SimpleNamespace(context={"thread_id": "thread-1"}, state={}),
url=_UNRESOLVED_LOOPBACK_URL,
tool_call_id="call-1",
)
assert result.update["messages"][0].content == "Error: Refusing to browse a private, loopback, or metadata address"
manager.acquire_session.assert_not_called()
async def test_browser_navigate_and_capture_resolves_off_loop(tmp_path) -> None:
manager = MagicMock()
with (
patch.object(browser_tools, "_get_tool_config", return_value={}),
patch.object(browser_tools, "get_browser_session_manager", return_value=manager),
pytest.raises(ValueError, match="Refusing to browse a private, loopback, or metadata address"),
):
await browser_tools.navigate_and_capture(thread_id="thread-1", url=_UNRESOLVED_LOOPBACK_URL, outputs_path=tmp_path)
manager.acquire_session.assert_not_called()
async def test_browser_request_guard_resolves_off_loop() -> None:
# The context route guard screens every redirect hop and subresource on the
# shared Playwright loop, which also pumps Live frames for every session.
from deerflow.community.browser_automation.session import BrowserSession
captured = {}
class _FakeContext:
async def route(self, _pattern, handler):
captured["handler"] = handler
class _FakeRoute:
request = SimpleNamespace(url=_UNRESOLVED_LOOPBACK_URL)
aborted_with = None
async def abort(self, error_code):
self.aborted_with = error_code
async def continue_(self):
raise AssertionError("a loopback request must not continue")
session = BrowserSession(MagicMock(), headless=True, timeout_ms=1000, viewport={"width": 1000, "height": 500}, url_guard=browser_tools.validate_browser_url)
session._context = _FakeContext()
route = _FakeRoute()
with patch.object(browser_tools, "_get_tool_config", return_value={}):
await session._install_request_guard()
await captured["handler"](route)
assert route.aborted_with == "blockedbyclient"
async def test_browser_egress_proxy_resolves_off_loop() -> None:
# Chromium hands every hostname to the session's egress proxy, which runs on
# the same shared Playwright loop as the request guard.
proxy = BrowserEgressProxy(browser_tools.resolve_browser_egress)
with patch.object(browser_tools, "_get_tool_config", return_value={}):
proxy_url = await proxy.start()
try:
reader, writer = await asyncio.open_connection("127.0.0.1", int(proxy_url.rsplit(":", 1)[1]))
writer.write(b"\x05\x01\x00")
await writer.drain()
assert await reader.readexactly(2) == b"\x05\x00"
writer.write(b"\x05\x01\x00\x03\x05127.1\x00\x50")
await writer.drain()
reply = await reader.readexactly(10)
writer.close()
finally:
await proxy.close()
assert reply[1] == 0x02 # refused: 127.1 resolves to loopback