hermes-agent/tests/tools/test_browser_cdp_override.py
claudlos 0a75616514 security(browser): enforce cloud-metadata floor on all backends; CDP is non-local
browser_navigate's always-blocked cloud-metadata floor (169.254.169.254,
metadata.google.internal, ECS/Azure/GCP IMDS) was gated on
`not _is_local_backend()`, contradicting both the adjacent comment and the
is_always_blocked_url docstring ("denied regardless of backend"). A default
local headless Chromium on a cloud VM — or an off-host CDP browser — could
navigate to IMDS and read instance credentials into the model context. Make the
floor unconditional on the initial-nav and post-redirect paths.

Also: _is_local_backend() ignored a CDP override while _is_local_mode() honors
it, so an off-host CDP browser was treated as "local" and skipped the broader
private/internal SSRF check too. Treat a CDP override as non-local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 05:09:35 -07:00

374 lines
16 KiB
Python

from unittest.mock import Mock, patch
HOST = "example-host"
PORT = 9223
WS_URL = f"ws://{HOST}:{PORT}/devtools/browser/abc123"
HTTP_URL = f"http://{HOST}:{PORT}"
VERSION_URL = f"{HTTP_URL}/json/version"
class TestResolveCdpOverride:
def test_keeps_full_devtools_websocket_url(self):
from tools.browser_tool import _resolve_cdp_override
assert _resolve_cdp_override(WS_URL) == WS_URL
def test_resolves_http_discovery_endpoint_to_websocket(self):
from tools.browser_tool import _resolve_cdp_override
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"webSocketDebuggerUrl": WS_URL}
with patch("tools.browser_tool.requests.get", return_value=response) as mock_get:
resolved = _resolve_cdp_override(HTTP_URL)
assert resolved == WS_URL
mock_get.assert_called_once_with(VERSION_URL, timeout=10)
def test_resolves_bare_ws_hostport_to_discovery_websocket(self):
from tools.browser_tool import _resolve_cdp_override
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"webSocketDebuggerUrl": WS_URL}
with patch("tools.browser_tool.requests.get", return_value=response) as mock_get:
resolved = _resolve_cdp_override(f"ws://{HOST}:{PORT}")
assert resolved == WS_URL
mock_get.assert_called_once_with(VERSION_URL, timeout=10)
def test_falls_back_to_raw_url_when_discovery_fails(self):
from tools.browser_tool import _resolve_cdp_override
with patch("tools.browser_tool.requests.get", side_effect=RuntimeError("boom")):
assert _resolve_cdp_override(HTTP_URL) == HTTP_URL
def test_redacts_secret_query_params_in_success_log(self):
from tools.browser_tool import _resolve_cdp_override
raw = "https://cdp.example/json/version?access_token=super-secret-token-123456"
resolved_ws = "wss://cdp.example/devtools/browser/abc?token=super-secret-token-123456"
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"webSocketDebuggerUrl": resolved_ws}
with patch("tools.browser_tool.requests.get", return_value=response), \
patch("tools.browser_tool.logger.info") as mock_info:
resolved = _resolve_cdp_override(raw)
assert resolved == resolved_ws
mock_info.assert_called_once()
_, logged_raw, logged_ws = mock_info.call_args.args
assert "super-secret-token-123456" not in logged_raw
assert "super-secret-token-123456" not in logged_ws
assert "access_token=***" in logged_raw
assert "token=***" in logged_ws
def test_redacts_secret_query_params_in_failure_log(self):
from tools.browser_tool import _resolve_cdp_override
raw = "https://cdp.example?access_token=super-secret-token-123456"
secret_error = RuntimeError(
"upstream rejected https://cdp.example/json/version?access_token=super-secret-token-123456"
)
with patch("tools.browser_tool.requests.get", side_effect=secret_error), \
patch("tools.browser_tool.logger.warning") as mock_warning:
resolved = _resolve_cdp_override(raw)
assert resolved == raw
mock_warning.assert_called_once()
_, logged_raw, logged_version_url, logged_error = mock_warning.call_args.args
assert "super-secret-token-123456" not in logged_raw
assert "super-secret-token-123456" not in logged_version_url
assert "super-secret-token-123456" not in logged_error
assert "access_token=***" in logged_raw
assert "access_token=***" in logged_version_url
assert "access_token=***" in logged_error
assert logged_version_url.startswith("https://cdp.example")
def test_normalizes_provider_returned_http_cdp_url_when_creating_session(self, monkeypatch):
import tools.browser_tool as browser_tool
provider = Mock()
provider.create_session.return_value = {
"session_name": "cloud-session",
"bb_session_id": "bu_123",
"cdp_url": "https://cdp.browser-use.example/session",
"features": {"browser_use": True},
}
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"webSocketDebuggerUrl": WS_URL}
monkeypatch.setattr(browser_tool, "_active_sessions", {})
monkeypatch.setattr(browser_tool, "_session_last_activity", {})
monkeypatch.setattr(browser_tool, "_start_browser_cleanup_thread", lambda: None)
monkeypatch.setattr(browser_tool, "_update_session_activity", lambda task_id: None)
monkeypatch.setattr(browser_tool, "_get_cdp_override", lambda: "")
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: provider)
with patch("tools.browser_tool.requests.get", return_value=response) as mock_get:
session_info = browser_tool._get_session_info("task-browser-use")
assert session_info["cdp_url"] == WS_URL
provider.create_session.assert_called_once_with("task-browser-use")
mock_get.assert_called_once_with(
"https://cdp.browser-use.example/session/json/version",
timeout=10,
)
class TestGetCdpOverride:
def test_prefers_env_var_over_config(self, monkeypatch):
import tools.browser_tool as browser_tool
monkeypatch.setenv("BROWSER_CDP_URL", HTTP_URL)
monkeypatch.setattr(
browser_tool,
"read_raw_config",
lambda: {"browser": {"cdp_url": "http://config-host:9222"}},
raising=False,
)
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"webSocketDebuggerUrl": WS_URL}
with patch("tools.browser_tool.requests.get", return_value=response) as mock_get:
resolved = browser_tool._get_cdp_override()
assert resolved == WS_URL
mock_get.assert_called_once_with(VERSION_URL, timeout=10)
def test_uses_config_browser_cdp_url_when_env_missing(self, monkeypatch):
import tools.browser_tool as browser_tool
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
response = Mock()
response.raise_for_status.return_value = None
response.json.return_value = {"webSocketDebuggerUrl": WS_URL}
with patch("hermes_cli.config.read_raw_config", return_value={"browser": {"cdp_url": HTTP_URL}}), \
patch("tools.browser_tool.requests.get", return_value=response) as mock_get:
resolved = browser_tool._get_cdp_override()
assert resolved == WS_URL
mock_get.assert_called_once_with(VERSION_URL, timeout=10)
def test_camofox_yields_to_config_cdp_override(self, monkeypatch):
"""CAMOFOX_URL + a persistent browser.cdp_url config override must NOT
report camofox mode: the CDP browser takes precedence so navigation is
not routed through Camofox, and the CDP backend stays non-local for SSRF
checks. Regression for the env-only suppression gap (config CDP was
ignored, so CAMOFOX_URL + config CDP still dispatched to Camofox)."""
import tools.browser_camofox as bc
monkeypatch.setenv("CAMOFOX_URL", "http://localhost:9377")
monkeypatch.delenv("BROWSER_CDP_URL", raising=False)
# No CDP anywhere -> camofox mode is on.
with patch("hermes_cli.config.read_raw_config", return_value={}):
assert bc.is_camofox_mode() is True
# A config-only CDP override suppresses camofox.
with patch("hermes_cli.config.read_raw_config",
return_value={"browser": {"cdp_url": HTTP_URL}}):
assert bc.is_camofox_mode() is False
# The env override still suppresses camofox.
monkeypatch.setenv("BROWSER_CDP_URL", HTTP_URL)
with patch("hermes_cli.config.read_raw_config", return_value={}):
assert bc.is_camofox_mode() is False
class TestCreateCdpSession:
"""_create_cdp_session() must sanitize the CDP URL before logging.
PR #54851 added _sanitize_url_for_logs() and wired it into the three log
sites inside _resolve_cdp_override(). This test guards the fourth site
that was missed: the logger.info call inside _create_cdp_session(), which
receives the already-resolved CDP URL and could contain a query-string
token (e.g. wss://provider.example/session?token=secret).
"""
def test_redacts_token_in_session_creation_log(self):
from tools.browser_tool import _create_cdp_session
cdp_url_with_token = "wss://cdp.example/devtools/browser/abc?token=super-secret-token-999"
with patch("tools.browser_tool.logger.info") as mock_info:
result = _create_cdp_session("task-1", cdp_url_with_token)
assert result["cdp_url"] == cdp_url_with_token, "raw URL must be stored unmodified"
mock_info.assert_called_once()
logged_args = " ".join(str(a) for a in mock_info.call_args.args)
assert "super-secret-token-999" not in logged_args
assert "token=***" in logged_args
def test_plain_url_without_secrets_passes_through(self):
from tools.browser_tool import _create_cdp_session
plain_url = "ws://localhost:9222/devtools/browser/abc123"
with patch("tools.browser_tool.logger.info") as mock_info:
_create_cdp_session("task-2", plain_url)
logged_args = " ".join(str(a) for a in mock_info.call_args.args)
assert "localhost:9222" in logged_args
class TestCDPSupervisorTimeoutRedaction:
"""CDPSupervisor.start() TimeoutError must not expose raw CDP credentials.
The supervisor raises TimeoutError(f"... (cdp_url={self.cdp_url[:80]}...)")
when attach times out. A URL with a query-string token (e.g.
wss://provider.example/session?token=secret) would embed the raw secret
in the exception message, which propagates to caller logs and tracebacks.
"""
def _make_timed_out_supervisor(self, cdp_url: str):
"""Return a CDPSupervisor whose start() will time out immediately."""
import threading
from tools.browser_supervisor import CDPSupervisor
sup = CDPSupervisor.__new__(CDPSupervisor)
sup.task_id = "test-task"
sup.cdp_url = cdp_url
sup._start_error = None
sup._stop_requested = False
sup._loop = None
# _thread = None so the is_alive() early-return guard is skipped.
sup._thread = None
# _ready_event that never fires so wait() always returns False.
never_ready = threading.Event()
sup._ready_event = never_ready
return sup
def test_timeout_error_redacts_query_token(self):
cdp_url = "wss://cdp.example/devtools/browser/abc?token=super-secret-999"
sup = self._make_timed_out_supervisor(cdp_url)
with patch("threading.Thread") as mock_thread_cls, patch.object(sup, "stop"):
mock_thread_cls.return_value = Mock()
try:
sup.start(timeout=0.001)
except TimeoutError as exc:
msg = str(exc)
assert "super-secret-999" not in msg, (
"raw token must not appear in TimeoutError message"
)
assert "cdp_url=" in msg
else:
raise AssertionError("TimeoutError was not raised")
def test_timeout_error_preserves_plain_url(self):
plain_url = "ws://127.0.0.1:9222/devtools/browser/abc"
sup = self._make_timed_out_supervisor(plain_url)
with patch("threading.Thread") as mock_thread_cls, patch.object(sup, "stop"):
mock_thread_cls.return_value = Mock()
try:
sup.start(timeout=0.001)
except TimeoutError as exc:
assert "127.0.0.1:9222" in str(exc)
else:
raise AssertionError("TimeoutError was not raised")
class TestCDPSupervisorStartErrorRedaction:
"""CDPSupervisor.start() must not leak the CDP URL via the connect-error path.
The more common failure mode than attach-timeout: the first
websockets.connect(self.cdp_url) raises (bad URI, refused, TLS), the raw
exception is stashed as self._start_error, and start() re-raises it. Those
websockets exceptions embed the full raw cdp_url -- token and userinfo --
in their message. start() must re-raise a REDACTED error and must not leak
the secret via the exception message or the traceback cause chain.
"""
def _run_start_hitting_error(self, cdp_url: str, start_error: BaseException):
"""Invoke start() so it takes the _start_error re-raise branch.
start() clears _ready_event / _start_error and launches a thread, so we
can't pre-seed them. Instead we stub threading.Thread: the fake thread's
start() synchronously populates _start_error and sets the ready event,
exactly as the real supervisor loop does on a first-connect failure.
"""
import threading
from tools.browser_supervisor import CDPSupervisor
sup = CDPSupervisor.__new__(CDPSupervisor)
sup.task_id = "test-task"
sup.cdp_url = cdp_url
sup._start_error = None
sup._stop_requested = False
sup._loop = None
sup._thread = None
sup._ready_event = threading.Event()
def _fake_thread(*args, **kwargs):
fake = Mock()
def _start():
sup._start_error = start_error
sup._ready_event.set()
fake.start.side_effect = _start
fake.is_alive.return_value = False
return fake
with patch("threading.Thread", side_effect=_fake_thread), patch.object(sup, "stop"):
sup.start(timeout=5.0)
def test_start_error_redacts_query_token(self):
# A realistic websockets-style error embedding the raw URL + token.
raw = "wss://cdp.example/devtools/browser/abc?token=super-secret-999"
err = ValueError(f"{raw} isn't a valid URI: hostname isn't provided")
try:
self._run_start_hitting_error(raw, err)
except Exception as exc: # noqa: BLE001 - asserting on the surface
msg = str(exc)
assert "super-secret-999" not in msg, (
"raw token must not appear in the re-raised error message"
)
# The raw cause must be suppressed so it can't leak via traceback.
assert exc.__cause__ is None
assert getattr(exc, "__suppress_context__", False) is True
else:
raise AssertionError("start() did not re-raise the start error")
def test_start_error_redacts_userinfo_password(self):
raw = "wss://user:p4ssw0rd@cdp.example/devtools/browser/x"
err = ValueError(f"{raw} isn't a valid URI: hostname isn't provided")
try:
self._run_start_hitting_error(raw, err)
except Exception as exc: # noqa: BLE001
assert "p4ssw0rd" not in str(exc)
else:
raise AssertionError("start() did not re-raise the start error")
class TestRedactCdpErrorText:
"""The supervisor's error-text chokepoint masks credentials, keeps context."""
def test_masks_query_token_in_exception(self):
from tools.browser_supervisor import _redact_cdp_error_text
err = ConnectionError("connect wss://h/x?token=leak-me failed")
out = _redact_cdp_error_text(err)
assert "leak-me" not in out
def test_preserves_non_secret_context(self):
from tools.browser_supervisor import _redact_cdp_error_text
err = ConnectionError("connect ws://127.0.0.1:9222/x failed: refused")
out = _redact_cdp_error_text(err)
assert "127.0.0.1:9222" in out
assert "refused" in out