fix: scope private URL policy per profile

This commit is contained in:
atakan g 2026-07-20 22:41:04 +03:00 committed by Teknium
parent fa9217542d
commit 7b18de5f40
4 changed files with 111 additions and 16 deletions

View file

@ -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

View file

@ -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."""

View file

@ -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:

View file

@ -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: