From 47764f19f462c0b3a99865255f3b1dfae5098e74 Mon Sep 17 00:00:00 2001 From: srojk34 <286497132+srojk34@users.noreply.github.com> Date: Fri, 3 Jul 2026 05:18:15 +0300 Subject: [PATCH] fix(browser): apply private-page guard to browser_cdp frame_id routing browser_cdp's frame_id (OOPIF) path returned early via _browser_cdp_via_supervisor before _browser_cdp_private_guard ever ran, unlike the stateless path a few lines below. A model that navigated a cloud browser to a private/internal URL could still read page content by passing frame_id, bypassing the same SSRF/private-page boundary already enforced on Runtime.evaluate, Page.navigate, and other raw CDP calls. Apply the same guard call used by the stateless path before dispatching to the supervisor, so both routing modes share one boundary. --- tests/tools/test_browser_cdp_tool.py | 64 ++++++++++++++++++++++++++++ tools/browser_cdp_tool.py | 9 ++++ 2 files changed, 73 insertions(+) diff --git a/tests/tools/test_browser_cdp_tool.py b/tests/tools/test_browser_cdp_tool.py index 194800701..0c0b16e9b 100644 --- a/tests/tools/test_browser_cdp_tool.py +++ b/tests/tools/test_browser_cdp_tool.py @@ -430,6 +430,70 @@ def test_runtime_evaluate_blocked_when_current_page_is_private(monkeypatch): assert calls == [] +def test_frame_id_route_blocked_when_current_page_is_private(monkeypatch): + """frame_id routing (OOPIF via supervisor) must not bypass the guard + applied to the stateless path — same private-page boundary either way.""" + supervisor_calls = [] + + import tools.browser_tool as bt + + monkeypatch.setattr(bt, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr(bt, "_current_page_private_url", lambda task_id: PRIVATE_URL) + + def fake_supervisor_route(**kwargs): + supervisor_calls.append(kwargs) + return json.dumps({"success": True, "result": {"value": "private data"}}) + + monkeypatch.setattr( + browser_cdp_tool, "_browser_cdp_via_supervisor", fake_supervisor_route + ) + + result = json.loads( + browser_cdp_tool.browser_cdp( + method="Runtime.evaluate", + params={"expression": "document.body.innerText"}, + frame_id="frame-1", + task_id="task-1", + ) + ) + + assert "error" in result + assert PRIVATE_URL in result["error"] + assert "private or internal address" in result["error"] + assert supervisor_calls == [] + + +def test_frame_id_route_allowed_when_page_is_not_private(monkeypatch): + """Sanity check: the new guard call must not block ordinary frame_id + routing when the current page isn't private.""" + supervisor_calls = [] + + import tools.browser_tool as bt + + monkeypatch.setattr(bt, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr(bt, "_current_page_private_url", lambda task_id: None) + + def fake_supervisor_route(**kwargs): + supervisor_calls.append(kwargs) + return json.dumps({"success": True, "result": {"value": "ok"}}) + + monkeypatch.setattr( + browser_cdp_tool, "_browser_cdp_via_supervisor", fake_supervisor_route + ) + + result = json.loads( + browser_cdp_tool.browser_cdp( + method="Runtime.evaluate", + params={"expression": "document.title"}, + frame_id="frame-1", + task_id="task-1", + ) + ) + + assert result.get("success") is True + assert len(supervisor_calls) == 1 + + def test_page_navigate_to_private_url_blocked_before_cdp(monkeypatch): calls = [] diff --git a/tools/browser_cdp_tool.py b/tools/browser_cdp_tool.py index ca7497bb6..2df9a1660 100644 --- a/tools/browser_cdp_tool.py +++ b/tools/browser_cdp_tool.py @@ -428,6 +428,15 @@ def browser_cdp( # --- Route iframe-scoped calls through the supervisor --------------- if frame_id: + # Same private-page/SSRF boundary as the stateless path below — + # frame_id routing must not become the sibling bypass for it. + blocked = _browser_cdp_private_guard( + task_id=effective_task_id, + method=method, + params=params or {}, + ) + if blocked: + return blocked return _browser_cdp_via_supervisor( task_id=effective_task_id, frame_id=frame_id,