fix(browser): guard Camofox snapshot/vision/images on private pages

Follow-up to #56874, which added the Camofox private-page SSRF guard
(_camofox_current_page_private_url) but wired it only into the Camofox
eval path (_camofox_eval). The other Camofox content-read tools —
camofox_snapshot, camofox_get_images, and camofox_vision — still read the
current page's accessibility tree / images / screenshot without the
guard, so on a non-local Camofox backend they can return the content of
an intranet or cloud-metadata page (e.g. 169.254.169.254) that the
terminal itself can't reach.

Apply the same guard, gated on _eval_ssrf_guard_active (non-local
backend, not a local sidecar, allow_private_urls unset) and fail-open on
probe failure, matching the eval-path guard and the main-browser
snapshot/vision guards. camofox_back is intentionally not changed: its
target is unknown until navigation completes, and the subsequent content
read is already guarded.

Adds regression tests covering the three read tools blocking on a private
page, the public-page pass-through, and the guard-inactive no-probe path.
This commit is contained in:
Evo 2026-07-02 17:35:07 +08:00 committed by kshitij
parent 0a2d4a6eea
commit a4a562ff0c
2 changed files with 165 additions and 0 deletions

View file

@ -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

View file

@ -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",