mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-09 13:21:42 +00:00
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>
374 lines
16 KiB
Python
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
|