deer-flow/backend/tests/test_browser_router.py
Hyeonsang Cho 457aa3a492
fix(community): resolve SSRF-guarded hostnames off the event loop (#6140)
* test(blocking-io): catch socket.getaddrinfo on the event loop

Blockbuster wraps socket.socket methods but not the module-level
resolver, so a synchronous hostname lookup on the loop passes the strict
gate. The SSRF guard in deerflow.community.url_safety resolves with
socket.getaddrinfo from async web tools, and the anchors added next stay
green against that unfixed code without this rule.

Add socket.getaddrinfo to _PROJECT_BLOCKING_RULES, scoped to app.* and
deerflow.* callers. loop.getaddrinfo and asyncio.to_thread resolve in a
worker thread and are not flagged. The full make test-blocking-io suite
stays green with the rule active (187 passed).

* fix(community): resolve SSRF-guarded hostnames off the event loop

validate_public_http_url resolves hostnames with blocking
socket.getaddrinfo. The crawl4ai and Browserless web_fetch, web_capture,
browser_navigate, the Gateway browser navigate route, and the Live
stream's navigate input and seed all called it directly from async code,
so a slow DNS answer for a model- or user-chosen URL stalled the Gateway
event loop for the whole lookup.

Run the guard through asyncio.to_thread at each of those call sites, the
same way personal_network already offloads it. The screening result is
unchanged. fastcrw's web_fetch is a sync tool and personal MCP validation
already runs in a worker, so neither needed a change.

* docs(changelog): reference #6140 in the SSRF guard event-loop entry

* fix(browser): run the Playwright request guard off the shared browser loop

BrowserSession._install_request_guard screens every redirect hop, iframe
and subresource by calling the SSRF guard inside the async route
callback. The guard resolves hostnames with blocking socket.getaddrinfo,
and that callback runs on the process-wide Playwright loop, which also
pumps Live frames and input for every browser session, so one slow
lookup stalled all of them.

Run the guard through asyncio.to_thread there as well, and pin it with a
strict-gate anchor that drives the real route handler. Also note in the
url_safety docstring that the screen is check-time only: a caller that
re-resolves at connect time is not pinned to the vetted IPs.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-02 22:33:48 +08:00

392 lines
16 KiB
Python

import asyncio
import ipaddress
import json
import logging
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from _router_auth_helpers import make_authed_test_app
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.gateway.auth.models import User
from app.gateway.routers import browser as browser_router
from app.gateway.routers.browser import (
_negotiate_browser_frame_format,
_send_browser_frame,
_should_apply_browser_seed,
_ws_origin_allowed,
)
from deerflow.config.authorization_config import AuthorizationConfig
@pytest.fixture(autouse=True)
def _isolate_route_authorization(monkeypatch):
# Existing routing/ownership tests must not inherit local operator policies.
monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: AuthorizationConfig(enabled=False))
class _FakeWebSocket:
"""Minimal stand-in exposing only the headers ``_ws_origin_allowed`` reads."""
def __init__(self, headers: dict[str, str]):
self.headers = {k.lower(): v for k, v in headers.items()}
def _user(user_id: str = "browser-user") -> SimpleNamespace:
return SimpleNamespace(id=user_id)
def _browser_ws_app(thread_store=...):
app = FastAPI()
app.include_router(browser_router.router)
if thread_store is not ...:
app.state.thread_store = thread_store
return app
def _expect_ws_close(app: FastAPI, code: int, *, headers: dict[str, str] | None = None) -> None:
with TestClient(app) as client:
with pytest.raises(WebSocketDisconnect) as exc_info:
with client.websocket_connect("/api/threads/thread-1/browser/stream", headers=headers or {}):
pass
assert exc_info.value.code == code
def test_browser_stream_closes_4401_when_unauthenticated():
app = _browser_ws_app()
with patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=None)):
_expect_ws_close(app, 4401)
def test_browser_stream_closes_4403_for_cross_origin_upgrade():
app = _browser_ws_app()
with patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())):
_expect_ws_close(app, 4403, headers={"origin": "https://evil.example.com"})
def test_browser_stream_closes_4404_when_thread_store_missing():
app = _browser_ws_app()
with patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())):
_expect_ws_close(app, 4404)
@pytest.mark.parametrize("record", [None, {"user_id": None}, {"user_id": "another-user"}])
def test_browser_stream_rejects_missing_or_unowned_thread(record):
store = MagicMock()
store.check_access = AsyncMock(return_value=True)
store.get = AsyncMock(return_value=record)
app = _browser_ws_app(store)
with (
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
patch.object(browser_router, "_browser_tools_enabled", return_value=False),
):
_expect_ws_close(app, 4404)
store.get.assert_awaited_once_with("thread-1", user_id="browser-user")
def test_browser_stream_closes_4404_when_tools_disabled_for_owned_thread():
store = MagicMock()
store.check_access = AsyncMock(return_value=True)
store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": "browser-user"})
app = _browser_ws_app(store)
with (
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
patch.object(browser_router, "_browser_tools_enabled", return_value=False),
):
_expect_ws_close(app, 4404)
def test_browser_stream_closes_4501_when_browser_runtime_unavailable():
store = MagicMock()
store.check_access = AsyncMock(return_value=True)
store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": "browser-user"})
app = _browser_ws_app(store)
real_import = __import__
def fail_browser_runtime_import(name, globals=None, locals=None, fromlist=(), level=0):
if name == "deerflow.community.browser_automation" and "get_browser_session_manager" in fromlist:
raise ImportError("browser runtime unavailable")
return real_import(name, globals, locals, fromlist, level)
with (
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
patch("builtins.__import__", side_effect=fail_browser_runtime_import),
):
_expect_ws_close(app, 4501)
def test_browser_navigate_rejects_legacy_null_owner_thread():
user = User(email="browser@example.com", password_hash="x", system_role="user")
app = make_authed_test_app(user_factory=lambda: user)
app.include_router(browser_router.router)
app.state.thread_store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": None})
with (
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
patch("deerflow.community.browser_automation.navigate_and_capture", new=AsyncMock()),
):
response = TestClient(app).post(
"/api/threads/thread-1/browser/navigate",
json={"url": "https://example.com"},
)
assert response.status_code == 404
app.state.thread_store.get.assert_awaited_once_with("thread-1", user_id=str(user.id))
def test_browser_tools_disabled_when_cdp_risk_not_explicitly_accepted():
tool_cfg = SimpleNamespace(name="browser_navigate", model_extra={"cdp_url": "http://127.0.0.1:9222"})
app_config = SimpleNamespace(tools=[tool_cfg])
with (
patch("deerflow.config.get_app_config", return_value=app_config),
patch("app.gateway.browser_capability.browser_multi_worker_error", return_value=None),
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=object()),
):
assert browser_router._browser_tools_enabled() is False
def test_browser_tools_enabled_when_cdp_risk_explicitly_accepted():
tool_cfg = SimpleNamespace(
name="browser_navigate",
model_extra={"cdp_url": "http://127.0.0.1:9222", "allow_unguarded_cdp": True},
)
app_config = SimpleNamespace(tools=[tool_cfg])
with (
patch("deerflow.config.get_app_config", return_value=app_config),
patch("app.gateway.browser_capability.browser_multi_worker_error", return_value=None),
patch("app.gateway.browser_capability.importlib.util.find_spec", return_value=object()),
):
assert browser_router._browser_tools_enabled() is True
def test_browser_navigate_redacts_failure_url_from_logs_and_response(caplog):
user = User(email="browser@example.com", password_hash="x", system_role="user")
app = make_authed_test_app(user_factory=lambda: user)
app.include_router(browser_router.router)
app.state.thread_store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": str(user.id)})
failing_url = "https://example.com/callback?code=secret#fragment"
caplog.set_level(logging.ERROR, logger="app.gateway.routers.browser")
with (
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
patch(
"deerflow.community.browser_automation.navigate_and_capture",
new=AsyncMock(side_effect=RuntimeError(f"timed out opening {failing_url}")),
),
):
response = TestClient(app).post(
"/api/threads/thread-1/browser/navigate",
json={"url": failing_url},
)
assert response.status_code == 502
assert response.json() == {"detail": "Browser navigation failed"}
assert "https://example.com/callback" in caplog.text
assert "code=secret" not in caplog.text
assert "fragment" not in caplog.text
assert "secret" not in response.text
def test_browser_stream_seed_applies_to_blank_page():
assert _should_apply_browser_seed("about:blank", "https://github.com/bytedance/deer-flow")
def test_browser_stream_seed_applies_when_current_url_differs():
assert _should_apply_browser_seed(
"https://docs.byteplus.com/en/docs/InfoQuest/What_is_Info_Quest",
"https://github.com/bytedance/deer-flow",
)
def test_browser_stream_seed_ignores_hash_and_trailing_slash_for_same_page():
assert not _should_apply_browser_seed(
"https://github.com/bytedance/deer-flow/#readme",
"https://github.com/bytedance/deer-flow/",
)
def test_ws_origin_allowed_without_origin_header():
# Native ws clients / tests do not send Origin — allow them.
assert _ws_origin_allowed(_FakeWebSocket({"host": "app.example.com"})) is True
def test_ws_origin_allowed_same_origin_host():
# Browser page scheme (https) differs from the ws scheme, so same-origin
# compares host[:port] against the upgrade target Host.
ws = _FakeWebSocket({"origin": "https://app.example.com", "host": "app.example.com"})
assert _ws_origin_allowed(ws) is True
@pytest.mark.asyncio
async def test_send_browser_frame_uses_binary_websocket_message_when_requested():
websocket = MagicMock()
websocket.send_bytes = AsyncMock()
websocket.send_text = AsyncMock()
await _send_browser_frame(websocket, b"\xff\xd8jpeg", binary=True)
websocket.send_bytes.assert_awaited_once_with(b"\xff\xd8jpeg")
websocket.send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_send_browser_frame_keeps_legacy_base64_json_protocol():
websocket = MagicMock()
websocket.send_bytes = AsyncMock()
websocket.send_text = AsyncMock()
await _send_browser_frame(websocket, b"\xff\xd8jpeg", binary=False)
websocket.send_bytes.assert_not_awaited()
payload = json.loads(websocket.send_text.await_args.args[0])
assert payload == {"type": "frame", "data": "/9hq cGVn".replace(" ", "")}
@pytest.mark.asyncio
async def test_browser_frame_format_rejects_unknown_capability():
websocket = MagicMock()
websocket.query_params = {"frame_format": "avif"}
websocket.accept = AsyncMock()
websocket.send_text = AsyncMock()
websocket.close = AsyncMock()
result = await _negotiate_browser_frame_format(websocket)
assert result is None
websocket.accept.assert_awaited_once()
payload = json.loads(websocket.send_text.await_args.args[0])
assert payload["type"] == "error"
assert "frame_format" in payload["message"]
websocket.close.assert_awaited_once_with(code=1008)
@pytest.mark.asyncio
@pytest.mark.parametrize(("value", "expected"), [(None, False), ("binary", True)])
async def test_browser_frame_format_accepts_legacy_and_binary(value, expected):
websocket = MagicMock()
websocket.query_params = {} if value is None else {"frame_format": value}
websocket.accept = AsyncMock()
websocket.send_text = AsyncMock()
websocket.close = AsyncMock()
assert await _negotiate_browser_frame_format(websocket) is expected
websocket.accept.assert_awaited_once()
websocket.send_text.assert_not_awaited()
websocket.close.assert_not_awaited()
def test_ws_origin_allowed_rejects_cross_origin():
ws = _FakeWebSocket({"origin": "https://evil.example.com", "host": "app.example.com"})
assert _ws_origin_allowed(ws) is False
def test_ws_origin_allowed_rejects_malformed_origin():
ws = _FakeWebSocket({"origin": "not-a-url", "host": "app.example.com"})
assert _ws_origin_allowed(ws) is False
def test_ws_origin_allowed_honors_configured_cors_origin(monkeypatch):
monkeypatch.setenv("GATEWAY_CORS_ORIGINS", "https://console.example.com")
ws = _FakeWebSocket({"origin": "https://console.example.com", "host": "gateway.internal"})
assert _ws_origin_allowed(ws) is True
def test_ws_origin_allowed_honors_forwarded_host():
# Behind a proxy the real upgrade target is X-Forwarded-Host, not Host.
ws = _FakeWebSocket(
{
"origin": "https://app.example.com",
"host": "gateway.internal",
"x-forwarded-host": "app.example.com",
},
)
assert _ws_origin_allowed(ws) is True
def test_browser_frames_dirname_shared_between_tools_and_scanner():
"""The screenshots dir name must stay identical in the writer and the scanner.
Both sides import the single ``BROWSER_FRAMES_DIRNAME`` constant; this locks
that they resolve to the same value so the workspace-changes ignore cannot
silently drift away from where the browser tools write frames.
"""
from deerflow.community.browser_automation import tools as browser_tools
from deerflow.constants import BROWSER_FRAMES_DIRNAME
from deerflow.workspace_changes.scanner import EXCLUDED_DIR_NAMES
assert browser_tools._BROWSER_FRAMES_DIRNAME == BROWSER_FRAMES_DIRNAME
assert BROWSER_FRAMES_DIRNAME in EXCLUDED_DIR_NAMES
def test_validate_browser_url_rejects_private_and_non_http(monkeypatch):
"""WS seed / navigate events reuse the same SSRF policy as the agent tools.
With no ``allow_private_addresses`` override the shared validator must reject
loopback / metadata / non-http targets, so the live stream cannot be steered
at internal infrastructure.
"""
from deerflow.community.browser_automation import tools as browser_tools
from deerflow.community.browser_automation import validate_browser_url
# Isolate from any local config.yaml that may set allow_private_addresses.
monkeypatch.setattr(browser_tools, "_get_tool_config", lambda _tool_name: {})
assert validate_browser_url("http://169.254.169.254/latest/meta-data/") is not None
assert validate_browser_url("http://127.0.0.1:8001/") is not None
assert validate_browser_url("file:///etc/passwd") is not None
assert validate_browser_url("ftp://example.com") is not None
# A normal public URL passes (returns None = allowed).
monkeypatch.setattr(browser_tools, "_resolve_host_addresses", lambda _host: [ipaddress.ip_address("93.184.215.14")])
assert validate_browser_url("https://github.com/bytedance/deer-flow") is None
def test_browser_stream_resolves_seed_and_navigate_urls_off_the_loop():
store = MagicMock()
store.check_access = AsyncMock(return_value=True)
store.get = AsyncMock(return_value={"thread_id": "thread-1", "user_id": "browser-user"})
app = _browser_ws_app(store)
session = MagicMock()
for method in ("current_url", "navigate", "start_screencast", "stop_screencast", "tabs", "dispatch_input"):
setattr(session, method, AsyncMock())
session.current_url.return_value = "about:blank"
session.tabs.return_value = []
manager = MagicMock()
manager.acquire_session.return_value.__enter__.return_value = session
resolved_on_loop: dict[str, bool] = {}
def resolve(host: str) -> list[ipaddress._BaseAddress]:
try:
asyncio.get_running_loop()
except RuntimeError:
resolved_on_loop[host] = False
else:
resolved_on_loop[host] = True
return [ipaddress.ip_address("127.0.0.1" if host == "internal.example" else "93.184.215.14")]
from deerflow.community.browser_automation import tools as browser_tools
with (
patch.object(browser_router, "_authenticate_ws", AsyncMock(return_value=_user())),
patch.object(browser_router, "_browser_tools_enabled", return_value=True),
patch("deerflow.config.get_app_config", return_value=SimpleNamespace(get_tool_config=lambda _name: None)),
patch("deerflow.community.browser_automation.get_browser_session_manager", return_value=manager),
patch.object(browser_tools, "_get_tool_config", return_value={}),
patch.object(browser_tools, "_resolve_host_addresses", side_effect=resolve),
TestClient(app) as client,
client.websocket_connect("/api/threads/thread-1/browser/stream?seed=https://seed.example/") as ws,
):
ws.send_text(json.dumps({"type": "navigate", "url": "https://internal.example/"}))
while (payload := ws.receive_json())["type"] != "nav_rejected":
pass
assert payload["url"] == "https://internal.example/"
session.navigate.assert_awaited_once_with("https://seed.example/")
session.dispatch_input.assert_not_awaited()
assert resolved_on_loop == {"seed.example": False, "internal.example": False}