diff --git a/tests/tools/test_browser_ssrf_local.py b/tests/tools/test_browser_ssrf_local.py index 9536e09891de..728c594b2684 100644 --- a/tests/tools/test_browser_ssrf_local.py +++ b/tests/tools/test_browser_ssrf_local.py @@ -368,3 +368,33 @@ class TestAllowPrivateUrlsConfig: ) assert browser_tool._allow_private_urls() is False + + def test_profile_scoped_config_does_not_reuse_another_profiles_opt_out( + self, tmp_path + ): + """The browser's independent guard must follow the active profile.""" + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + allowed_home = tmp_path / "allowed" + blocked_home = tmp_path / "blocked" + allowed_home.mkdir() + blocked_home.mkdir() + (allowed_home / "config.yaml").write_text( + "browser:\n allow_private_urls: true\n", encoding="utf-8" + ) + (blocked_home / "config.yaml").write_text( + "browser:\n allow_private_urls: false\n", encoding="utf-8" + ) + + def under_profile(home): + token = set_hermes_home_override(home) + try: + return browser_tool._allow_private_urls() + finally: + reset_hermes_home_override(token) + + assert under_profile(allowed_home) is True + assert under_profile(blocked_home) is False diff --git a/tests/tools/test_url_safety.py b/tests/tools/test_url_safety.py index 1dcf36fd0c10..1eb4da14a9be 100644 --- a/tests/tools/test_url_safety.py +++ b/tests/tools/test_url_safety.py @@ -590,6 +590,42 @@ class TestGlobalAllowPrivateUrls: monkeypatch.setenv("HERMES_ALLOW_PRIVATE_URLS", "false") assert _global_allow_private_urls() is True + def test_profile_scoped_config_does_not_reuse_another_profiles_opt_out( + self, tmp_path, monkeypatch + ): + """Multiplexed profiles must resolve their own private-URL policy.""" + from hermes_constants import ( + reset_hermes_home_override, + set_hermes_home_override, + ) + + monkeypatch.delenv("HERMES_ALLOW_PRIVATE_URLS", raising=False) + allowed_home = tmp_path / "allowed" + blocked_home = tmp_path / "blocked" + allowed_home.mkdir() + blocked_home.mkdir() + (allowed_home / "config.yaml").write_text( + "security:\n allow_private_urls: true\n", encoding="utf-8" + ) + (blocked_home / "config.yaml").write_text( + "security:\n allow_private_urls: false\n", encoding="utf-8" + ) + monkeypatch.setattr( + socket, + "getaddrinfo", + lambda *_args, **_kwargs: [(2, 1, 6, "", ("10.0.0.8", 0))], + ) + + def under_profile(home): + token = set_hermes_home_override(home) + try: + return is_safe_url("http://profile-private.test/resource") + finally: + reset_hermes_home_override(token) + + assert under_profile(allowed_home) is True + assert under_profile(blocked_home) is False + class TestAllowPrivateUrlsIntegration: """Integration tests: is_safe_url respects the global toggle.""" diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 7a712c875657..62cf85b43248 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -66,7 +66,11 @@ from typing import Dict, Any, Optional, List, Tuple, Union from pathlib import Path from agent.auxiliary_client import call_llm from agent.redact import redact_cdp_url -from hermes_constants import agent_browser_runnable, get_hermes_home +from hermes_constants import ( + agent_browser_runnable, + get_hermes_home, + get_hermes_home_override, +) from utils import env_int, is_truthy_value from hermes_cli.config import DEFAULT_CONFIG, cfg_get from hermes_cli._subprocess_compat import windows_hide_flags @@ -1419,26 +1423,39 @@ def _last_session_key(task_id: str) -> str: 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). + Reads ``config["browser"]["allow_private_urls"]``. Single-profile calls + cache the result for the process lifetime; multiplexed profile turns resolve + their context-local config on each call. Defaults to ``False`` (SSRF + protection active). """ global _cached_allow_private_urls, _allow_private_urls_resolved + + # The profile multiplexer scopes config with a ContextVar while sharing + # this module. Never reuse another profile's private-network opt-out. + if get_hermes_home_override() is not None: + return _resolve_allow_private_urls() + if _allow_private_urls_resolved: return _cached_allow_private_urls _allow_private_urls_resolved = True - _cached_allow_private_urls = False # safe default + _cached_allow_private_urls = _resolve_allow_private_urls() + return _cached_allow_private_urls + + +def _resolve_allow_private_urls() -> bool: + """Read the browser private-URL toggle from the active config scope.""" try: from hermes_cli.config import read_raw_config cfg = read_raw_config() browser_cfg = cfg.get("browser", {}) if isinstance(browser_cfg, dict): - _cached_allow_private_urls = is_truthy_value( + return is_truthy_value( browser_cfg.get("allow_private_urls"), default=False ) except Exception as e: logger.debug("Could not read allow_private_urls from config: %s", e) - return _cached_allow_private_urls + return False def _socket_safe_tmpdir() -> str: diff --git a/tools/url_safety.py b/tools/url_safety.py index 00cf1b498dbc..6442fe4bf55f 100644 --- a/tools/url_safety.py +++ b/tools/url_safety.py @@ -34,6 +34,7 @@ import re from typing import Any, Optional from urllib.parse import parse_qsl, quote, unquote, urljoin, urlparse, urlsplit, urlunsplit +from hermes_constants import get_hermes_home_override from utils import is_truthy_value logger = logging.getLogger(__name__) @@ -224,23 +225,36 @@ def _global_allow_private_urls() -> bool: 2. ``security.allow_private_urls`` in config.yaml 3. ``browser.allow_private_urls`` in config.yaml (legacy / backward compat) - Result is cached for the process lifetime. + The single-profile result is cached for the process lifetime. Multiplexed + profile turns bypass that process-global cache because their config root is + context-local; ``read_raw_config()`` already provides path/mtime caching. """ global _allow_private_resolved, _cached_allow_private + + # A multiplex gateway serves several independently configured profiles in + # one process. Reusing the first profile's opt-out here would let it disable + # private-network blocking for every later profile in that process. + if get_hermes_home_override() is not None: + return _resolve_allow_private_urls() + if _allow_private_resolved: return _cached_allow_private _allow_private_resolved = True - _cached_allow_private = False # safe default + _cached_allow_private = _resolve_allow_private_urls() + return _cached_allow_private + + +def _resolve_allow_private_urls() -> bool: + """Resolve the effective private-URL toggle from the active config scope.""" # 1. Env var override (highest priority) env_val = os.getenv("HERMES_ALLOW_PRIVATE_URLS", "").strip().lower() if env_val in {"true", "1", "yes"}: - _cached_allow_private = True - return _cached_allow_private + return True if env_val in {"false", "0", "no"}: # Explicit false — don't fall through to config - return _cached_allow_private + return False # 2. Config file try: @@ -251,20 +265,18 @@ def _global_allow_private_urls() -> bool: if isinstance(sec, dict) and is_truthy_value( sec.get("allow_private_urls"), default=False ): - _cached_allow_private = True - return _cached_allow_private + return True # browser.allow_private_urls (legacy fallback) browser = cfg.get("browser", {}) if isinstance(browser, dict) and is_truthy_value( browser.get("allow_private_urls"), default=False ): - _cached_allow_private = True - return _cached_allow_private + return True except Exception: # Config unavailable (e.g. tests, early import) — keep default pass - return _cached_allow_private + return False def _reset_allow_private_cache() -> None: