mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* 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.
462 lines
18 KiB
Python
462 lines
18 KiB
Python
"""Pinned browser egress: the SOCKS5 proxy, its policy, and the session wiring.
|
|
|
|
The proxy tests drive the real asyncio server with a minimal SOCKS5 client, so
|
|
the protocol bytes Chromium exchanges with it are exercised end to end. The last
|
|
test repeats the rebinding scenario against a real headless Chromium and is
|
|
skipped when Playwright is not installed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import ipaddress
|
|
import socket
|
|
import sys
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from types import ModuleType, SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from deerflow.community import url_safety
|
|
from deerflow.community.browser_automation import egress
|
|
from deerflow.community.browser_automation import session as session_mod
|
|
from deerflow.community.browser_automation import tools as browser_tools
|
|
from deerflow.community.browser_automation.egress import BrowserEgressProxy
|
|
from deerflow.community.browser_automation.session import BrowserSession
|
|
|
|
|
|
async def _start_echo_server() -> tuple[asyncio.Server, int]:
|
|
async def echo(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
writer.write(await reader.read(1024))
|
|
await writer.drain()
|
|
writer.close()
|
|
|
|
server = await asyncio.start_server(echo, host="127.0.0.1", port=0)
|
|
return server, server.sockets[0].getsockname()[1]
|
|
|
|
|
|
async def _socks_connect(proxy_url: str, address_type: int, address: bytes, port: int, *, command: int = 0x01, methods: bytes = b"\x00"):
|
|
"""Run the client half of a SOCKS5 CONNECT; return (method reply, reply code, reader, writer)."""
|
|
proxy_port = int(proxy_url.rsplit(":", 1)[1])
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
writer.write(bytes([5, len(methods)]) + methods)
|
|
await writer.drain()
|
|
_version, method = await reader.readexactly(2)
|
|
if method == 0xFF:
|
|
return method, None, reader, writer
|
|
writer.write(bytes([5, command, 0, address_type]) + address + port.to_bytes(2, "big"))
|
|
await writer.drain()
|
|
reply = await reader.readexactly(10)
|
|
return method, reply[1], reader, writer
|
|
|
|
|
|
def _domain(host: str) -> bytes:
|
|
return bytes([len(host)]) + host.encode("ascii")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_connects_to_the_vetted_address_not_the_requested_name():
|
|
echo, echo_port = await _start_echo_server()
|
|
asked: list[str] = []
|
|
|
|
def resolve(host: str) -> list[str]:
|
|
asked.append(host)
|
|
return ["127.0.0.1"]
|
|
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
_method, reply, reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("rebind.example"), echo_port)
|
|
assert reply == 0x00
|
|
writer.write(b"ping")
|
|
await writer.drain()
|
|
assert await reader.read(1024) == b"ping"
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
echo.close()
|
|
assert asked == ["rebind.example"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_refuses_a_host_the_resolver_rejects_without_connecting():
|
|
connections: list[str] = []
|
|
|
|
async def record(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
connections.append("upstream")
|
|
writer.close()
|
|
|
|
upstream = await asyncio.start_server(record, host="127.0.0.1", port=0)
|
|
upstream_port = upstream.sockets[0].getsockname()[1]
|
|
|
|
def resolve(host: str) -> list[str]:
|
|
raise ValueError("Error: Refusing to browse a private, loopback, or metadata address")
|
|
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
_method, reply, _reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("metadata.example"), upstream_port)
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
upstream.close()
|
|
assert reply == 0x02 # connection not allowed by ruleset
|
|
assert connections == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_hands_ip_literals_to_the_resolver():
|
|
echo, echo_port = await _start_echo_server()
|
|
asked: list[str] = []
|
|
|
|
def resolve(host: str) -> list[str]:
|
|
asked.append(host)
|
|
return [host]
|
|
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
_method, reply, _reader, writer = await _socks_connect(await proxy.start(), 0x01, socket.inet_aton("127.0.0.1"), echo_port)
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
echo.close()
|
|
assert reply == 0x00
|
|
assert asked == ["127.0.0.1"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_falls_through_to_the_next_vetted_address():
|
|
echo, echo_port = await _start_echo_server()
|
|
# The echo server listens on IPv4 loopback only, so the IPv6 loopback answer
|
|
# is refused (or unavailable) and only the second vetted address connects.
|
|
proxy = BrowserEgressProxy(lambda _host: ["::1", "127.0.0.1"])
|
|
try:
|
|
_method, reply, _reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("multi.example"), echo_port)
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
echo.close()
|
|
assert reply == 0x00
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_reports_unreachable_when_no_vetted_address_answers():
|
|
with socket.socket() as probe:
|
|
probe.bind(("127.0.0.1", 0))
|
|
closed_port = probe.getsockname()[1]
|
|
|
|
proxy = BrowserEgressProxy(lambda _host: ["127.0.0.1"])
|
|
try:
|
|
_method, reply, _reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("down.example"), closed_port)
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
assert reply == 0x04
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_rejects_commands_other_than_connect():
|
|
resolve = MagicMock()
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
_method, reply, _reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("bind.example"), 80, command=0x02)
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
assert reply == 0x07
|
|
resolve.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_rejects_clients_that_require_authentication():
|
|
resolve = MagicMock()
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
method, _reply, _reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("auth.example"), 80, methods=b"\x02")
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
assert method == 0xFF
|
|
resolve.assert_not_called()
|
|
|
|
|
|
def _blocking_resolver():
|
|
"""A resolver that hangs until released, like a wedged getaddrinfo."""
|
|
release = threading.Event()
|
|
|
|
def resolve(_host: str) -> list[str]:
|
|
release.wait(timeout=10)
|
|
return ["127.0.0.1"]
|
|
|
|
return resolve, release
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_reports_a_resolver_fault_as_general_failure(caplog):
|
|
def resolve(_host: str) -> list[str]:
|
|
raise RuntimeError("config store unavailable")
|
|
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
_method, reply, _reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("fault.example"), 80)
|
|
writer.close()
|
|
finally:
|
|
await proxy.close()
|
|
# 0x01 (general failure), distinct from the 0x02 a policy refusal returns.
|
|
assert reply == 0x01
|
|
assert "browser egress resolver failed for fault.example:80" in caplog.text
|
|
assert "config store unavailable" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_bounds_a_stuck_resolution(monkeypatch):
|
|
monkeypatch.setattr(egress, "_RESOLVE_TIMEOUT_S", 0.2)
|
|
resolve, release = _blocking_resolver()
|
|
proxy = BrowserEgressProxy(resolve)
|
|
try:
|
|
_method, reply, _reader, writer = await asyncio.wait_for(_socks_connect(await proxy.start(), 0x03, _domain("wedged.example"), 80), timeout=2.0)
|
|
writer.close()
|
|
finally:
|
|
release.set()
|
|
await proxy.close()
|
|
assert reply == 0x04
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_close_is_bounded_by_a_tunnel_it_could_not_sweep(monkeypatch):
|
|
monkeypatch.setattr(egress, "_CLOSE_TIMEOUT_S", 0.2)
|
|
proxy = BrowserEgressProxy(MagicMock())
|
|
proxy_port = int((await proxy.start()).rsplit(":", 1)[1])
|
|
reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
try:
|
|
while not proxy._client_writers:
|
|
await asyncio.sleep(0.01)
|
|
# Simulate a connection whose handler registered after the sweep: its
|
|
# transport stays open in the 15 s handshake read, which would otherwise
|
|
# hold Server.wait_closed() and with it session close.
|
|
proxy._client_writers.clear()
|
|
await asyncio.wait_for(proxy.close(), timeout=1.0)
|
|
finally:
|
|
writer.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_drops_a_connection_accepted_after_close():
|
|
resolve = MagicMock(side_effect=ValueError("refused"))
|
|
proxy = BrowserEgressProxy(resolve)
|
|
await proxy.start()
|
|
await proxy.close()
|
|
reader = asyncio.StreamReader()
|
|
reader.feed_data(b"\x05\x01\x00\x05\x01\x00\x03" + _domain("late.example") + (80).to_bytes(2, "big"))
|
|
reader.feed_eof()
|
|
writer = MagicMock()
|
|
writer.drain = AsyncMock()
|
|
writer.wait_closed = AsyncMock()
|
|
|
|
# A handler scheduled for a connection accepted just before close() runs now.
|
|
await asyncio.wait_for(proxy._handle(reader, writer), timeout=1.0)
|
|
|
|
writer.close.assert_called_once()
|
|
resolve.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_proxy_close_ends_open_tunnels():
|
|
held = asyncio.Event()
|
|
|
|
async def hold(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
held.set()
|
|
await reader.read()
|
|
writer.close()
|
|
|
|
upstream = await asyncio.start_server(hold, host="127.0.0.1", port=0)
|
|
upstream_port = upstream.sockets[0].getsockname()[1]
|
|
proxy = BrowserEgressProxy(lambda _host: ["127.0.0.1"])
|
|
try:
|
|
_method, reply, reader, writer = await _socks_connect(await proxy.start(), 0x03, _domain("idle.example"), upstream_port)
|
|
assert reply == 0x00
|
|
await asyncio.wait_for(held.wait(), timeout=1.0)
|
|
await asyncio.wait_for(proxy.close(), timeout=2.0)
|
|
assert await asyncio.wait_for(reader.read(), timeout=1.0) == b""
|
|
writer.close()
|
|
finally:
|
|
upstream.close()
|
|
|
|
|
|
def test_resolve_public_addresses_returns_the_vetted_answer():
|
|
public = [ipaddress.ip_address("93.184.215.14"), ipaddress.ip_address("2606:2800:21f:cb07:6820:80da:af6b:8b2c")]
|
|
|
|
assert url_safety.resolve_public_addresses("example.com", resolver=lambda _host: public) == public
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("hostname", "answer", "message"),
|
|
[
|
|
("localhost", None, "Error: Refusing to connect to a private or loopback address"),
|
|
("169.254.169.254", None, "Error: Refusing to connect to a private, loopback, or metadata address"),
|
|
("unresolvable.example", [], "Error: URL host could not be resolved"),
|
|
("mixed.example", ["93.184.215.14", "10.0.0.5"], "Error: Refusing to connect to a private, loopback, or metadata address"),
|
|
],
|
|
)
|
|
def test_resolve_public_addresses_refuses_with_the_validator_message(hostname, answer, message):
|
|
def resolver(_host):
|
|
assert answer is not None, "literal and blocked hosts must not be resolved"
|
|
return [ipaddress.ip_address(address) for address in answer]
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
url_safety.resolve_public_addresses(hostname, resolver=resolver)
|
|
assert str(exc_info.value) == message
|
|
|
|
|
|
def test_resolve_browser_egress_applies_the_browse_policy():
|
|
answers = {"public.example": ["93.184.215.14", "93.184.215.14"], "internal.example": ["10.0.0.5"]}
|
|
|
|
def resolver(host):
|
|
return [ipaddress.ip_address(address) for address in answers[host]]
|
|
|
|
with patch.object(browser_tools, "_get_tool_config", return_value={}), patch.object(browser_tools, "_resolve_host_addresses", side_effect=resolver):
|
|
assert browser_tools.resolve_browser_egress("public.example") == ["93.184.215.14"]
|
|
with pytest.raises(ValueError, match="Refusing to browse a private, loopback, or metadata address"):
|
|
browser_tools.resolve_browser_egress("internal.example")
|
|
|
|
with patch.object(browser_tools, "_get_tool_config", return_value={"allow_private_addresses": True}), patch.object(browser_tools, "_resolve_host_addresses", side_effect=resolver):
|
|
assert browser_tools.resolve_browser_egress("internal.example") == ["10.0.0.5"]
|
|
|
|
|
|
def _fake_playwright_modules():
|
|
fake_async_api = ModuleType("playwright.async_api")
|
|
fake_async_api.async_playwright = MagicMock()
|
|
fake_playwright = ModuleType("playwright")
|
|
fake_playwright.async_api = fake_async_api
|
|
return patch.dict(sys.modules, {"playwright": fake_playwright, "playwright.async_api": fake_async_api})
|
|
|
|
|
|
def _fake_browser():
|
|
page = MagicMock()
|
|
page.is_closed.return_value = False
|
|
context = MagicMock()
|
|
context.new_page = AsyncMock(return_value=page)
|
|
context.route = AsyncMock()
|
|
browser = MagicMock()
|
|
browser.is_connected.return_value = True
|
|
browser.new_context = AsyncMock(return_value=context)
|
|
browser.close = AsyncMock()
|
|
context.close = AsyncMock()
|
|
chromium = MagicMock()
|
|
chromium.launch = AsyncMock(return_value=browser)
|
|
return chromium
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_launches_chromium_through_its_egress_proxy_and_closes_it():
|
|
session = BrowserSession(MagicMock(), headless=True, timeout_ms=1000, viewport={"width": 1000, "height": 500}, egress_resolver=lambda _host: [])
|
|
chromium = _fake_browser()
|
|
session._playwright = SimpleNamespace(chromium=chromium, stop=AsyncMock())
|
|
|
|
with _fake_playwright_modules():
|
|
await session._ensure_page()
|
|
|
|
proxy = chromium.launch.await_args.kwargs["proxy"]
|
|
assert proxy["server"].startswith("socks5://127.0.0.1:")
|
|
assert proxy["bypass"] == "<-loopback>"
|
|
proxy_port = int(proxy["server"].rsplit(":", 1)[1])
|
|
_reader, writer = await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
writer.close()
|
|
|
|
await session._close()
|
|
with pytest.raises(OSError):
|
|
await asyncio.open_connection("127.0.0.1", proxy_port)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_without_egress_resolver_launches_directly():
|
|
session = BrowserSession(MagicMock(), headless=True, timeout_ms=1000, viewport={"width": 1000, "height": 500})
|
|
chromium = _fake_browser()
|
|
session._playwright = SimpleNamespace(chromium=chromium)
|
|
|
|
with _fake_playwright_modules():
|
|
await session._ensure_page()
|
|
|
|
chromium.launch.assert_awaited_once_with(headless=True, proxy=None)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manager_passes_the_egress_resolver_to_new_sessions():
|
|
manager = session_mod.BrowserSessionManager()
|
|
with patch.object(session_mod, "ensure_browser_worker_compatibility"), patch.object(manager, "_ensure_loop", return_value=MagicMock()):
|
|
session = manager.get_session("egress-thread", egress_resolver=browser_tools.resolve_browser_egress)
|
|
assert session._egress_resolver is browser_tools.resolve_browser_egress
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_browser_tools_acquire_sessions_with_pinned_egress(tmp_path):
|
|
manager = MagicMock()
|
|
page_snapshot = MagicMock(url="https://example.com/", title="Example")
|
|
session = MagicMock()
|
|
session.navigate = AsyncMock(return_value=page_snapshot)
|
|
session.screenshot_bytes = AsyncMock(side_effect=RuntimeError("no screenshot"))
|
|
manager.acquire_session.return_value.__enter__.return_value = session
|
|
manager.get_session.return_value = session
|
|
with (
|
|
patch.object(browser_tools, "_get_tool_config", return_value={}),
|
|
patch.object(browser_tools, "get_browser_session_manager", return_value=manager),
|
|
patch.object(browser_tools, "_resolve_host_addresses", return_value=[ipaddress.ip_address("93.184.215.14")]),
|
|
):
|
|
await browser_tools.navigate_and_capture(thread_id="thread-1", url="https://example.com/", outputs_path=tmp_path)
|
|
browser_tools._resolve_session(SimpleNamespace(context={"thread_id": "thread-1"}, state={}), "browser_navigate")
|
|
|
|
assert manager.acquire_session.call_args.kwargs["egress_resolver"] is browser_tools.resolve_browser_egress
|
|
assert manager.get_session.call_args.kwargs["egress_resolver"] is browser_tools.resolve_browser_egress
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_real_chromium_cannot_follow_a_rebinding_answer():
|
|
"""A name that passes both URL screens but answers loopback to the connect is refused."""
|
|
pytest.importorskip("playwright.async_api")
|
|
|
|
class _Internal(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
body = b"<title>metadata</title>iam-credentials"
|
|
self.send_response(200)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *_args):
|
|
pass
|
|
|
|
internal = ThreadingHTTPServer(("127.0.0.1", 0), _Internal)
|
|
threading.Thread(target=internal.serve_forever, daemon=True).start()
|
|
# Chromium resolves *.localhost to loopback by itself, so without the pinned
|
|
# egress proxy this navigation would reach the internal server directly.
|
|
url = f"http://rebind.localhost:{internal.server_address[1]}/"
|
|
lookups = 0
|
|
|
|
def rebinding_resolver(_host):
|
|
nonlocal lookups
|
|
lookups += 1
|
|
# The navigate screen and the request guard see a public answer; the
|
|
# lookup the connection actually uses sees loopback.
|
|
return [ipaddress.ip_address("93.184.215.14" if lookups <= 2 else "127.0.0.1")]
|
|
|
|
session_mod.reset_browser_session_manager()
|
|
manager = session_mod.get_browser_session_manager()
|
|
runtime = SimpleNamespace(context={"thread_id": "rebind-thread"}, state={"thread_data": {}})
|
|
try:
|
|
with (
|
|
patch.object(browser_tools, "_get_tool_config", return_value={"timeout_ms": 15000}),
|
|
patch.object(browser_tools, "_resolve_host_addresses", side_effect=rebinding_resolver),
|
|
patch.object(browser_tools, "_capture_step_screenshot", AsyncMock(return_value=None)),
|
|
):
|
|
result = await browser_tools.browser_navigate_tool.coroutine(runtime=runtime, url=url, tool_call_id="call-1")
|
|
finally:
|
|
await manager.close_session("rebind-thread")
|
|
session_mod.reset_browser_session_manager()
|
|
internal.shutdown()
|
|
|
|
content = result.update["messages"][0].content
|
|
assert "ERR_SOCKS_CONNECTION_FAILED" in content
|
|
assert "iam-credentials" not in content
|
|
# navigate screen, request guard, then the proxy's own lookup for the connect
|
|
assert lookups >= 3
|