mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
0a2d4a6eea
commit
a4a562ff0c
2 changed files with 165 additions and 0 deletions
119
tests/tools/test_browser_camofox_private_page_guard.py
Normal file
119
tests/tools/test_browser_camofox_private_page_guard.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue