fix(tools): make browser SSRF check configurable via browser.allow_private_urls (#4198)
* fix(tools): skip SSRF check in local browser mode The SSRF protection added in #3041 blocks all private/internal addresses unconditionally in browser_navigate(). This prevents legitimate local development use cases (localhost testing, LAN device access) when using the local Chromium backend. The SSRF check is only meaningful for cloud browsers (Browserbase, BrowserUse) where the agent could reach internal resources on a remote machine. In local mode, the user already has full terminal and network access, so the check adds no security value. This change makes the SSRF check conditional on _get_cloud_provider(), keeping full protection in cloud mode while allowing private addresses in local mode. * fix(tools): make SSRF check configurable via browser.allow_private_urls Replace unconditional SSRF check with a configurable setting. Default (False) keeps existing security behavior. Setting to True allows navigating to private/internal IPs for local dev and LAN use cases. --------- Co-authored-by: Nils (Norya) <nils@begou.dev>
This commit is contained in:
@@ -245,6 +245,7 @@ DEFAULT_CONFIG = {
|
||||
"inactivity_timeout": 120,
|
||||
"command_timeout": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)
|
||||
"record_sessions": False, # Auto-record browser sessions as WebM videos
|
||||
"allow_private_urls": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)
|
||||
},
|
||||
|
||||
# Filesystem checkpoints — automatic snapshots before destructive file ops.
|
||||
|
||||
163
tests/tools/test_browser_ssrf_local.py
Normal file
163
tests/tools/test_browser_ssrf_local.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""Tests that browser_navigate SSRF checks respect 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.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from tools import browser_tool
|
||||
|
||||
|
||||
def _make_browser_result(url="https://example.com"):
|
||||
"""Return a mock successful browser command result."""
|
||||
return {"success": True, "data": {"title": "OK", "url": url}}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-navigation SSRF check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPreNavigationSsrf:
|
||||
PRIVATE_URL = "http://127.0.0.1:8080/dashboard"
|
||||
|
||||
@pytest.fixture()
|
||||
def _common_patches(self, monkeypatch):
|
||||
"""Shared patches for pre-navigation tests that pass the SSRF check."""
|
||||
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "check_website_access", lambda url: None)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_get_session_info",
|
||||
lambda task_id: {
|
||||
"session_name": f"s_{task_id}",
|
||||
"bb_session_id": None,
|
||||
"cdp_url": None,
|
||||
"features": {"local": True},
|
||||
"_first_nav": False,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_run_browser_command",
|
||||
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)."""
|
||||
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
|
||||
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."""
|
||||
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."""
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Post-redirect SSRF check
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPostRedirectSsrf:
|
||||
PUBLIC_URL = "https://example.com/redirect"
|
||||
PRIVATE_FINAL_URL = "http://192.168.1.1/internal"
|
||||
|
||||
@pytest.fixture()
|
||||
def _common_patches(self, monkeypatch):
|
||||
"""Shared patches for redirect tests."""
|
||||
monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "check_website_access", lambda url: None)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_get_session_info",
|
||||
lambda task_id: {
|
||||
"session_name": f"s_{task_id}",
|
||||
"bb_session_id": None,
|
||||
"cdp_url": None,
|
||||
"features": {"local": True},
|
||||
"_first_nav": False,
|
||||
},
|
||||
)
|
||||
|
||||
def test_blocks_redirect_to_private_by_default(self, monkeypatch, _common_patches):
|
||||
"""Redirects to private addresses are blocked when setting is False."""
|
||||
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 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."""
|
||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: True)
|
||||
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_allows_redirect_to_public_regardless_of_setting(self, monkeypatch, _common_patches):
|
||||
"""Redirects to public addresses always pass."""
|
||||
final = "https://example.com/final"
|
||||
monkeypatch.setattr(browser_tool, "_allow_private_urls", lambda: False)
|
||||
monkeypatch.setattr(browser_tool, "_is_safe_url", lambda url: True)
|
||||
monkeypatch.setattr(
|
||||
browser_tool,
|
||||
"_run_browser_command",
|
||||
lambda *a, **kw: _make_browser_result(url=final),
|
||||
)
|
||||
|
||||
result = json.loads(browser_tool.browser_navigate(self.PUBLIC_URL))
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["url"] == final
|
||||
@@ -237,6 +237,8 @@ _PROVIDER_REGISTRY: Dict[str, type] = {
|
||||
|
||||
_cached_cloud_provider: Optional[CloudBrowserProvider] = None
|
||||
_cloud_provider_resolved = False
|
||||
_allow_private_urls_resolved = False
|
||||
_allow_private_urls: Optional[bool] = None
|
||||
|
||||
|
||||
def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
@@ -265,6 +267,31 @@ def _get_cloud_provider() -> Optional[CloudBrowserProvider]:
|
||||
return _cached_cloud_provider
|
||||
|
||||
|
||||
def _allow_private_urls() -> bool:
|
||||
"""Return whether the browser is allowed to navigate to private/internal addresses.
|
||||
|
||||
Reads ``config["browser"]["allow_private_urls"]`` once and caches the result
|
||||
for the process lifetime. Defaults to ``False`` (SSRF protection active).
|
||||
"""
|
||||
global _allow_private_urls, _allow_private_urls_resolved
|
||||
if _allow_private_urls_resolved:
|
||||
return _allow_private_urls
|
||||
|
||||
_allow_private_urls_resolved = True
|
||||
_allow_private_urls = False # safe default
|
||||
try:
|
||||
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
|
||||
config_path = hermes_home / "config.yaml"
|
||||
if config_path.exists():
|
||||
import yaml
|
||||
with open(config_path) as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
_allow_private_urls = bool(cfg.get("browser", {}).get("allow_private_urls"))
|
||||
except Exception as e:
|
||||
logger.debug("Could not read allow_private_urls from config: %s", e)
|
||||
return _allow_private_urls
|
||||
|
||||
|
||||
def _socket_safe_tmpdir() -> str:
|
||||
"""Return a short temp directory path suitable for Unix domain sockets.
|
||||
|
||||
@@ -1038,8 +1065,10 @@ def browser_navigate(url: str, task_id: Optional[str] = None) -> str:
|
||||
Returns:
|
||||
JSON string with navigation result (includes stealth features info on first nav)
|
||||
"""
|
||||
# SSRF protection — block private/internal addresses before navigating
|
||||
if not _is_safe_url(url):
|
||||
# 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):
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": "Blocked: URL targets a private or internal address",
|
||||
@@ -1081,7 +1110,7 @@ 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 final_url and final_url != url and not _is_safe_url(final_url):
|
||||
if 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({
|
||||
|
||||
Reference in New Issue
Block a user