diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 97df597d5..9d7f545b2 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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. diff --git a/tests/tools/test_browser_ssrf_local.py b/tests/tools/test_browser_ssrf_local.py new file mode 100644 index 000000000..44d3b8ea1 --- /dev/null +++ b/tests/tools/test_browser_ssrf_local.py @@ -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 diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 33a1c8ef6..03aa6106b 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -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({