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:
Nils
2026-03-31 11:11:55 +02:00
committed by GitHub
parent 086ec5590d
commit 50302ed70a
3 changed files with 196 additions and 3 deletions

View File

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