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:
Teknium
2026-03-31 10:40:13 -07:00
committed by GitHub
parent fad3f338d1
commit cca0996a28
2 changed files with 120 additions and 30 deletions

View File

@@ -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
addresses are blocked. When set to ``True``, they are allowed — useful for
local development, LAN access, and Hermes self-testing.
Local backends (Camofox, headless Chromium without a cloud provider) skip
SSRF checks entirely — the agent already has full local-network access via
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
@@ -47,8 +51,11 @@ class TestPreNavigationSsrf:
lambda *a, **kw: _make_browser_result(),
)
def test_blocks_private_url_by_default(self, monkeypatch, _common_patches):
"""SSRF protection is on when allow_private_urls is not set (False)."""
# -- Cloud mode: SSRF active -----------------------------------------------
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, "_is_safe_url", lambda url: False)
@@ -57,27 +64,19 @@ class TestPreNavigationSsrf:
assert result["success"] is False
assert "private or internal address" in result["error"]
def test_blocks_private_url_when_setting_false(self, monkeypatch, _common_patches):
"""SSRF protection is on when allow_private_urls is explicitly False."""
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 False
def test_allows_private_url_when_setting_true(self, monkeypatch, _common_patches):
"""Private URLs are allowed when allow_private_urls is True."""
def test_cloud_allows_private_url_when_setting_true(self, monkeypatch, _common_patches):
"""Private URLs pass in cloud mode when allow_private_urls is True."""
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
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)
result = json.loads(browser_tool.browser_navigate(self.PRIVATE_URL))
assert result["success"] is True
def test_allows_public_url_regardless_of_setting(self, monkeypatch, _common_patches):
"""Public URLs always pass regardless of the allow_private_urls setting."""
def test_cloud_allows_public_url(self, monkeypatch, _common_patches):
"""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, "_is_safe_url", lambda url: True)
@@ -85,6 +84,56 @@ class TestPreNavigationSsrf:
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
@@ -112,8 +161,11 @@ class TestPostRedirectSsrf:
},
)
def test_blocks_redirect_to_private_by_default(self, monkeypatch, _common_patches):
"""Redirects to private addresses are blocked when setting is False."""
# -- Cloud mode: redirect SSRF active --------------------------------------
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, "_is_safe_url", lambda url: "192.168" not in url,
@@ -129,8 +181,9 @@ class TestPostRedirectSsrf:
assert result["success"] is False
assert "redirect landed on a private/internal address" in result["error"]
def test_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches):
"""Redirects to private addresses are allowed when setting is True."""
def test_cloud_allows_redirect_to_private_when_setting_true(self, monkeypatch, _common_patches):
"""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, "_is_safe_url", lambda url: "192.168" not in url,
@@ -146,9 +199,30 @@ class TestPostRedirectSsrf:
assert result["success"] is True
assert result["url"] == self.PRIVATE_FINAL_URL
def test_allows_redirect_to_public_regardless_of_setting(self, monkeypatch, _common_patches):
"""Redirects to public addresses always pass."""
# -- Local mode: redirect SSRF skipped -------------------------------------
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"
monkeypatch.setattr(browser_tool, "_is_local_backend", lambda: False)
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
monkeypatch.setattr(

View File

@@ -267,6 +267,19 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
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:
"""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)
"""
# SSRF protection — block private/internal addresses before navigating.
# Can be opted out via ``browser.allow_private_urls`` in config for local
# development or LAN access use cases.
if not _allow_private_urls() and not _is_safe_url(url):
# Skipped for local backends (Camofox, headless Chromium without a cloud
# provider) because the agent already has full local network access via
# 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({
"success": False,
"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
# private/internal address, block the result so the model can't read
# 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
_run_browser_command(effective_task_id, "open", ["about:blank"], timeout=10)
return json.dumps({