mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
* 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>
163 lines
6.5 KiB
Python
163 lines
6.5 KiB
Python
"""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
|