diff --git a/tests/tools/test_browser_camofox_private_page_guard.py b/tests/tools/test_browser_camofox_private_page_guard.py new file mode 100644 index 00000000000..410209d7305 --- /dev/null +++ b/tests/tools/test_browser_camofox_private_page_guard.py @@ -0,0 +1,119 @@ +"""Regression tests for the Camofox private-page read guards. + +Companion to ``tests/tools/test_browser_private_page_action_guard.py`` (which +covers the agent-browser path) and ``test_browser_eval_ssrf.py`` (which covers +the Camofox *eval* path added in #56874). These cover the remaining Camofox +content-read tools — snapshot / vision / image-extraction — which read current +page state and, on a non-local backend, could otherwise leak the content of a +private/internal page the terminal itself can't reach. +""" + +import json + +import pytest + +from tools import browser_camofox + + +PRIVATE_URL = "http://169.254.169.254/latest/meta-data/" + + +@pytest.fixture +def _session(monkeypatch): + session = {"tab_id": "tab-1", "user_id": "user-1"} + monkeypatch.setattr(browser_camofox, "_get_session", lambda task_id: session) + return session + + +def _block_active(monkeypatch): + """Make the SSRF guard active and the current page resolve to a private URL.""" + from tools import browser_tool + + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr( + browser_tool, "_camofox_current_page_private_url", lambda tab_id, user_id: PRIVATE_URL + ) + + +def _block_inactive_guard(monkeypatch): + """SSRF guard inactive (local backend / allow_private_urls).""" + from tools import browser_tool + + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: False) + + def fail_probe(tab_id, user_id): + raise AssertionError("must not probe page URL when the SSRF guard is inactive") + + monkeypatch.setattr(browser_tool, "_camofox_current_page_private_url", fail_probe) + + +def _public_page(monkeypatch): + from tools import browser_tool + + monkeypatch.setattr(browser_tool, "_eval_ssrf_guard_active", lambda task_id: True) + monkeypatch.setattr( + browser_tool, "_camofox_current_page_private_url", lambda tab_id, user_id: None + ) + + +@pytest.mark.parametrize( + ("tool_call", "action_phrase"), + [ + (lambda: browser_camofox.camofox_snapshot(task_id="t1"), "read a page snapshot"), + (lambda: browser_camofox.camofox_get_images(task_id="t1"), "extract page images"), + (lambda: browser_camofox.camofox_vision("what is here?", task_id="t1"), "capture a screenshot"), + ], +) +def test_private_page_blocks_camofox_reads(monkeypatch, _session, tool_call, action_phrase): + _block_active(monkeypatch) + + # Any HTTP call would mean the guard failed to short-circuit before the read. + def fail_http(*_args, **_kwargs): + raise AssertionError("Camofox HTTP call should not run on a private page") + + monkeypatch.setattr(browser_camofox, "_get", fail_http) + monkeypatch.setattr(browser_camofox, "_get_raw", fail_http) + monkeypatch.setattr(browser_camofox, "_post", fail_http) + + out = json.loads(tool_call()) + + assert out["success"] is False + assert PRIVATE_URL in out["error"] + assert "private or internal address" in out["error"] + assert action_phrase in out["error"] + + +def test_snapshot_still_runs_when_page_is_public(monkeypatch, _session): + _public_page(monkeypatch) + + monkeypatch.setattr( + browser_camofox, + "_get", + lambda path, params=None: {"snapshot": "- heading \"Hi\" [e1]", "refsCount": 1}, + ) + + out = json.loads(browser_camofox.camofox_snapshot(task_id="t1")) + + assert out["success"] is True + assert out["element_count"] == 1 + + +def test_guard_inactive_does_not_probe(monkeypatch, _session): + """When the SSRF guard is inactive the read proceeds WITHOUT probing the URL. + + This is the branch most likely to silently regress if the guard condition is + ever inverted, so it is exercised explicitly (mirrors the agent-browser + guard test). + """ + _block_inactive_guard(monkeypatch) + + monkeypatch.setattr( + browser_camofox, + "_get", + lambda path, params=None: {"snapshot": "- heading \"Hi\" [e1]", "refsCount": 1}, + ) + + out = json.loads(browser_camofox.camofox_snapshot(task_id="t1")) + + assert out["success"] is True + assert out["element_count"] == 1 diff --git a/tools/browser_camofox.py b/tools/browser_camofox.py index 9d4c8c9bc96..fe11256aa76 100644 --- a/tools/browser_camofox.py +++ b/tools/browser_camofox.py @@ -570,6 +570,40 @@ def camofox_navigate(url: str, task_id: Optional[str] = None) -> str: return tool_error(str(e), success=False) +def _camofox_private_page_block(session: Dict[str, Any], task_id: Optional[str], action: str) -> Optional[str]: + """Return a blocked payload when the current Camofox page is private/internal. + + Mirrors the eval-path guard added for ``_camofox_eval`` (browser_tool.py): + Camofox snapshot / vision / image-extraction all read current page state, so + on a non-local backend they can leak the content of an intranet/metadata + page the terminal itself can't reach. The gate matches ``browser_snapshot`` + / ``browser_vision`` — only active when the SSRF guard applies (non-local + backend, not a local sidecar, ``allow_private_urls`` unset). Fail-open on + probe failure, matching the sibling guards. + + Imports are deferred to call time because ``browser_tool`` imports this + module; importing it at module load would create a circular import. + """ + from tools.browser_tool import ( + _camofox_current_page_private_url, + _eval_ssrf_guard_active, + ) + + if not _eval_ssrf_guard_active(task_id or "default"): + return None + blocked_url = _camofox_current_page_private_url(session["tab_id"], session["user_id"]) + if not blocked_url: + return None + return json.dumps({ + "success": False, + "error": ( + "Blocked: page URL targets a private or internal address " + f"({blocked_url}). Refusing to {action} on this page in this " + "browser mode." + ), + }, ensure_ascii=False) + + def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, user_task: Optional[str] = None) -> str: """Get accessibility tree snapshot from Camofox.""" @@ -578,6 +612,10 @@ def camofox_snapshot(full: bool = False, task_id: Optional[str] = None, if not session["tab_id"]: return tool_error("No browser session. Call browser_navigate first.", success=False) + blocked = _camofox_private_page_block(session, task_id, "read a page snapshot") + if blocked: + return blocked + data = _get( f"/tabs/{session['tab_id']}/snapshot", params={"userId": session["user_id"]}, @@ -742,6 +780,10 @@ def camofox_get_images(task_id: Optional[str] = None) -> str: if not session["tab_id"]: return tool_error("No browser session. Call browser_navigate first.", success=False) + blocked = _camofox_private_page_block(session, task_id, "extract page images") + if blocked: + return blocked + import re data = _get( @@ -786,6 +828,10 @@ def camofox_vision(question: str, annotate: bool = False, if not session["tab_id"]: return tool_error("No browser session. Call browser_navigate first.", success=False) + blocked = _camofox_private_page_block(session, task_id, "capture a screenshot") + if blocked: + return blocked + # Get screenshot as binary PNG resp = _get_raw( f"/tabs/{session['tab_id']}/screenshot",