mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-01 01:51:44 +00:00
fix(browser): skip SSRF check for local backends (Camofox, headless Chromium) (#4292)
The SSRF protection added in #3041 blocks all private/internal addresses unconditionally in browser_navigate(). This prevents legitimate local use cases (localhost apps, LAN devices) when using Camofox or the built-in headless Chromium without a cloud provider. The check is only meaningful for cloud backends (Browserbase, BrowserUse) where the agent could reach internal resources on a remote machine. Local backends give the user full terminal and network access already — the SSRF check adds zero security value. Add _is_local_backend() helper that returns True when Camofox is active or no cloud provider is configured. Both the pre-navigation and post-redirect SSRF checks now skip when running locally. The browser.allow_private_urls config option remains available as an explicit opt-out for cloud mode.
This commit is contained in:
parent
fad3f338d1
commit
cca0996a28
2 changed files with 120 additions and 30 deletions
|
|
@ -1,8 +1,12 @@
|
||||||
"""Tests that browser_navigate SSRF checks respect the allow_private_urls setting.
|
"""Tests that browser_navigate SSRF checks respect local-backend mode and
|
||||||
|
the allow_private_urls setting.
|
||||||
|
|
||||||
When ``browser.allow_private_urls`` is ``False`` (default), private/internal
|
Local backends (Camofox, headless Chromium without a cloud provider) skip
|
||||||
addresses are blocked. When set to ``True``, they are allowed — useful for
|
SSRF checks entirely — the agent already has full local-network access via
|
||||||
local development, LAN access, and Hermes self-testing.
|
the terminal tool.
|
||||||
|
|
||||||
|
Cloud backends (Browserbase, BrowserUse) enforce SSRF by default. Users
|
||||||
|
can opt out for cloud mode via ``browser.allow_private_urls: true``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -47,8 +51,11 @@ class TestPreNavigationSsrf:
|
||||||
lambda *a, **kw: _make_browser_result(),
|
lambda *a, **kw: _make_browser_result(),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_blocks_private_url_by_default(self, monkeypatch, _common_patches):
|
# -- Cloud mode: SSRF active -----------------------------------------------
|
||||||
"""SSRF protection is on when allow_private_urls is not set (False)."""
|
|
||||||
|
def test_cloud_blocks_private_url_by_default(self, monkeypatch, _common_patches):
|
||||||
|
"""SSRF protection blocks private URLs in cloud mode."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
||||||
|
|
||||||
|
|
@ -57,27 +64,19 @@ class TestPreNavigationSsrf:
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "private or internal address" in result["error"]
|
assert "private or internal address" in result["error"]
|
||||||
|
|
||||||
def test_blocks_private_url_when_setting_false(self, monkeypatch, _common_patches):
|
def test_cloud_allows_private_url_when_setting_true(self, monkeypatch, _common_patches):
|
||||||
"""SSRF protection is on when allow_private_urls is explicitly False."""
|
"""Private URLs pass in cloud mode when allow_private_urls is True."""
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
|
||||||
|
|
||||||
result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL))
|
|
||||||
|
|
||||||
assert result["success"] is False
|
|
||||||
|
|
||||||
def test_allows_private_url_when_setting_true(self, monkeypatch, _common_patches):
|
|
||||||
"""Private URLs are allowed when allow_private_urls is True."""
|
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True)
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True)
|
||||||
# _is_safe_url would block this, but the setting overrides it
|
|
||||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
||||||
|
|
||||||
result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL))
|
result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL))
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
||||||
def test_allows_public_url_regardless_of_setting(self, monkeypatch, _common_patches):
|
def test_cloud_allows_public_url(self, monkeypatch, _common_patches):
|
||||||
"""Public URLs always pass regardless of the allow_private_urls setting."""
|
"""Public URLs always pass in cloud mode."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||||
|
|
||||||
|
|
@ -85,6 +84,56 @@ class TestPreNavigationSsrf:
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
|
||||||
|
# -- Local mode: SSRF skipped ----------------------------------------------
|
||||||
|
|
||||||
|
def test_local_allows_private_url(self, monkeypatch, _common_patches):
|
||||||
|
"""Local backends skip SSRF — private URLs are always allowed."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True)
|
||||||
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: False)
|
||||||
|
|
||||||
|
result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
def test_local_allows_public_url(self, monkeypatch, _common_patches):
|
||||||
|
"""Local backends pass public URLs too (sanity check)."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True)
|
||||||
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||||
|
|
||||||
|
result = json.loads(browser_tool.browser_navigate("https://example.com"))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _is_local_backend() unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsLocalBackend:
|
||||||
|
def test_camofox_is_local(self, monkeypatch):
|
||||||
|
"""Camofox mode counts as a local backend."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: True)
|
||||||
|
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: "anything")
|
||||||
|
|
||||||
|
assert browser_tool._is_local_backend() is True
|
||||||
|
|
||||||
|
def test_no_cloud_provider_is_local(self, monkeypatch):
|
||||||
|
"""No cloud provider configured → local backend."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False)
|
||||||
|
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: None)
|
||||||
|
|
||||||
|
assert browser_tool._is_local_backend() is True
|
||||||
|
|
||||||
|
def test_cloud_provider_is_not_local(self, monkeypatch):
|
||||||
|
"""Cloud provider configured and not Camofox → NOT local."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False)
|
||||||
|
monkeypatch.setattr(browser_tool, "_get_cloud_provider", lambda: "bb")
|
||||||
|
|
||||||
|
assert browser_tool._is_local_backend() is False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Post-redirect SSRF check
|
# Post-redirect SSRF check
|
||||||
|
|
@ -112,8 +161,11 @@ class TestPostRedirectSsrf:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_blocks_redirect_to_private_by_default(self, monkeypatch, _common_patches):
|
# -- Cloud mode: redirect SSRF active --------------------------------------
|
||||||
"""Redirects to private addresses are blocked when setting is False."""
|
|
||||||
|
def test_cloud_blocks_redirect_to_private(self, monkeypatch, _common_patches):
|
||||||
|
"""Redirects to private addresses are blocked in cloud mode."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
browser_tool, "_is_safe_url", lambda url: "192.168" not in url,
|
browser_tool, "_is_safe_url", lambda url: "192.168" not in url,
|
||||||
|
|
@ -129,8 +181,9 @@ class TestPostRedirectSsrf:
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
assert "redirect landed on a private/internal address" in result["error"]
|
assert "redirect landed on a private/internal address" in result["error"]
|
||||||
|
|
||||||
def test_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches):
|
def test_cloud_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches):
|
||||||
"""Redirects to private addresses are allowed when setting is True."""
|
"""Redirects to private addresses pass in cloud mode with allow_private_urls."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True)
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
browser_tool, "_is_safe_url", lambda url: "192.168" not in url,
|
browser_tool, "_is_safe_url", lambda url: "192.168" not in url,
|
||||||
|
|
@ -146,9 +199,30 @@ class TestPostRedirectSsrf:
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["url"] == self.PRIVATE_FINAL_URL
|
assert result["url"] == self.PRIVATE_FINAL_URL
|
||||||
|
|
||||||
def test_allows_redirect_to_public_regardless_of_setting(self, monkeypatch, _common_patches):
|
# -- Local mode: redirect SSRF skipped -------------------------------------
|
||||||
"""Redirects to public addresses always pass."""
|
|
||||||
|
def test_local_allows_redirect_to_private(self, monkeypatch, _common_patches):
|
||||||
|
"""Redirects to private addresses pass in local mode."""
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: True)
|
||||||
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
browser_tool, "_is_safe_url", lambda url: "192.168" not in url,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
browser_tool,
|
||||||
|
"_run_browser_command",
|
||||||
|
lambda *a, **kw: _make_browser_result(url=self.PRIVATE_FINAL_URL),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL))
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["url"] == self.PRIVATE_FINAL_URL
|
||||||
|
|
||||||
|
def test_cloud_allows_redirect_to_public(self, monkeypatch, _common_patches):
|
||||||
|
"""Redirects to public addresses always pass (cloud mode)."""
|
||||||
final = "https://example.com/final"
|
final = "https://example.com/final"
|
||||||
|
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
|
||||||
|
|
@ -267,6 +267,19 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||||
return _cached_cloud_provider
|
return _cached_cloud_provider
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_backend() -> bool:
|
||||||
|
"""Return True when the browser runs locally (no cloud provider).
|
||||||
|
|
||||||
|
SSRF protection is only meaningful for cloud backends (Browserbase,
|
||||||
|
BrowserUse) where the agent could reach internal resources on a remote
|
||||||
|
machine. For local backends — Camofox, or the built-in headless
|
||||||
|
Chromium without a cloud provider — the user already has full terminal
|
||||||
|
and network access on the same machine, so the check adds no security
|
||||||
|
value.
|
||||||
|
"""
|
||||||
|
return _is_camofox_mode() or _get_cloud_provider() is None
|
||||||
|
|
||||||
|
|
||||||
def _allow_private_urls() -> bool:
|
def _allow_private_urls() -> bool:
|
||||||
"""Return whether the browser is allowed to navigate to private/internal addresses.
|
"""Return whether the browser is allowed to navigate to private/internal addresses.
|
||||||
|
|
||||||
|
|
@ -1066,9 +1079,11 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||||
JSON string with navigation result (includes stealth features info on first nav)
|
JSON string with navigation result (includes stealth features info on first nav)
|
||||||
"""
|
"""
|
||||||
# SSRF protection — block private/internal addresses before navigating.
|
# SSRF protection — block private/internal addresses before navigating.
|
||||||
# Can be opted out via ``browser.allow_private_urls`` in config for local
|
# Skipped for local backends (Camofox, headless Chromium without a cloud
|
||||||
# development or LAN access use cases.
|
# provider) because the agent already has full local network access via
|
||||||
if not _allow_private_urls() and not _is_safe_url(url):
|
# the terminal tool. Can also be opted out for cloud mode via
|
||||||
|
# ``browser.allow_private_urls`` in config.
|
||||||
|
if not _is_local_backend() and not _allow_private_urls() and not _is_safe_url(url):
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
"success": False,
|
"success": False,
|
||||||
"error": "Blocked: URL targets a private or internal address",
|
"error": "Blocked: URL targets a private or internal address",
|
||||||
|
|
@ -1110,7 +1125,8 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||||
# Post-redirect SSRF check — if the browser followed a redirect to a
|
# Post-redirect SSRF check — if the browser followed a redirect to a
|
||||||
# private/internal address, block the result so the model can't read
|
# private/internal address, block the result so the model can't read
|
||||||
# internal content via subsequent browser_snapshot calls.
|
# internal content via subsequent browser_snapshot calls.
|
||||||
if not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url):
|
# Skipped for local backends (same rationale as the pre-nav check).
|
||||||
|
if not _is_local_backend() and not _allow_private_urls() and final_url and final_url != url and not _is_safe_url(final_url):
|
||||||
# Navigate away to a blank page to prevent snapshot leaks
|
# Navigate away to a blank page to prevent snapshot leaks
|
||||||
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
|
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
|
||||||
return json.dumps({
|
return json.dumps({
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue