Skip to content

feat(security): add global toggle for private/internal URL resolution#14054

Closed
kshitijk4poor wants to merge 1 commit into
mainfrom
feat/ssrf-allow-private-urls
Closed

feat(security): add global toggle for private/internal URL resolution#14054
kshitijk4poor wants to merge 1 commit into
mainfrom
feat/ssrf-allow-private-urls

Conversation

@kshitijk4poor

@kshitijk4poor kshitijk4poor commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

Users on networks where DNS resolves external domains to private IP ranges are completely blocked from using Hermes's web tools, browser, vision URL fetching, and gateway media downloads. This affects:

  • OpenWrt routers that use 198.18.0.0/15 (IANA benchmarking range) for DNS resolution
  • Corporate proxies / split-tunnel VPNs that resolve all domains locally
  • Tailscale/WireGuard setups using 100.64.0.0/10 (CGNAT range)

Example from a user's environment — nousresearch.com resolves to a private IP:

$ nslookup nousresearch.com
Name:    nousresearch.com
Address: 198.18.23.183

Python's ipaddress.is_private returns True for 198.18.23.183 (it's in the IANA benchmarking range 198.18.0.0/15), so Hermes's SSRF guard blocks the request with:

Blocked: URL targets a private or internal network address

This affects all 23 call sites that use is_safe_url() — web_extract, web_crawl, browser_navigate, vision_analyze URL downloads, and media downloads across all 13 gateway platform adapters (Telegram, Discord, Slack, Matrix, Feishu, etc.).

Previously, only the browser tool had an escape hatch (browser.allow_private_urls in config.yaml), but the other 21 call sites had no bypass mechanism at all.

Competitor Analysis

Audited OpenCode, Cline, and Claude Code (closed source). None of them have any SSRF protection:

Agent SSRF Protection Metadata Blocking IP Validation Toggle
Hermes (this PR) Full pre-flight + redirect guards Always blocked (5 IPs + entire link-local range + 2 hostnames) All RFC 1918 + CGNAT + link-local + multicast Yes
OpenCode None (scheme check only) None None N/A
Cline None (URL goes straight to page.goto) None None N/A
Claude Code Closed source Unknown Unknown Unknown

We're already well ahead. This PR makes the protection configurable without weakening the security-critical parts.

Solution

A single global toggle in tools/url_safety.py that all 23 call sites inherit automatically — no changes needed at individual call sites.

Configuration (three ways, in priority order):

  1. Env var: HERMES_ALLOW_PRIVATE_URLS=true
  2. Config: security.allow_private_urls: true in ~/.hermes/config.yaml
  3. Legacy: browser.allow_private_urls: true (existing key, now promotes globally)

Security guarantees preserved — hardened after competitive review

When the toggle is enabled, cloud metadata endpoints and the entire link-local range are ALWAYS blocked:

Target Blocked? Why
metadata.google.internal Always GCP metadata hostname (pre-DNS)
metadata.goog Always GCP metadata hostname (pre-DNS)
169.254.169.254 Always AWS/GCP/Azure/DO/Oracle metadata
169.254.170.2 Always AWS ECS task metadata (IAM creds)
169.254.169.253 Always Azure IMDS wire server
169.254.0.0/16 (entire range) Always All link-local — no legit agent target
fd00:ec2::254 Always AWS metadata (IPv6)
100.100.100.200 Always Alibaba Cloud metadata
192.168.1.1 Allowed with toggle Legitimate local network
198.18.23.183 Allowed with toggle OpenWrt proxy resolution
100.64.0.1 Allowed with toggle CGNAT/Tailscale

Key design: hostname checks (metadata.google.internal, metadata.goog) run before DNS resolution, so on networks where these hostnames resolve to local proxy IPs, they're still blocked by the string match.

Files changed

  • tools/url_safety.py — Core change: _global_allow_private_urls() with cached config read, expanded _ALWAYS_BLOCKED_IPS (5 cloud metadata IPs), new _ALWAYS_BLOCKED_NETWORKS (entire 169.254.0.0/16), is_safe_url() checks toggle after blocking metadata
  • hermes_cli/config.pysecurity.allow_private_urls: false added to DEFAULT_CONFIG
  • tests/tools/test_url_safety.py — 37 new tests across 3 test classes

Tests

  • 79 url_safety tests pass (42 existing + 37 new)
  • 98 browser SSRF + website policy + vision tests pass (no regressions)
  • E2E verified with real imports: toggle works via env var, config.yaml, and browser legacy fallback; all 6 cloud metadata IPs/ranges stay blocked with toggle on; public IPs unaffected

New test coverage:

  • TestGlobalAllowPrivateUrls (11 tests) — toggle defaults, env var parsing, config.yaml security + browser fallback, precedence, caching
  • TestAllowPrivateUrlsIntegration (16 tests) — full is_safe_url() integration: private/benchmark/CGNAT/localhost IPs allowed with toggle, and all metadata endpoints always blocked: AWS (169.254.169.254), ECS (169.254.170.2), Azure wire server (169.254.169.253), Alibaba (100.100.100.200), AWS IPv6 (fd00:ec2::254), full link-local range (169.254.x.x), metadata hostnames, DNS failures, empty URLs

How to use

Users hitting this issue add one line to ~/.hermes/config.yaml:

security:
  allow_private_urls: true

Or set the env var HERMES_ALLOW_PRIVATE_URLS=true for a quick workaround.

Comment thread tools/url_safety.py Dismissed
Comment thread tools/url_safety.py Dismissed
@alt-glitch alt-glitch added type/security Security vulnerability or hardening P2 Medium — degraded but workaround exists comp/tools Tool registry, model_tools, toolsets area/config Config system, migrations, profiles tool/browser Browser automation (CDP, Playwright) tool/web Web search and extraction labels Apr 22, 2026
…ution

On networks using OpenWrt routers, corporate proxies, or VPNs that
resolve external domains to private IP ranges (198.18.0.0/15, 100.64.x,
etc.), Hermes blocks ALL outbound requests because is_safe_url() treats
any private IP as an SSRF attack vector.

This adds a global toggle that disables private-IP blocking across all
23 call sites (web_tools, vision_tools, browser_tool, and 13 gateway
platform adapters) from a single config key.

Security guarantee: cloud metadata endpoints are ALWAYS blocked
regardless of the toggle:
- Hostnames: metadata.google.internal, metadata.goog
- IPs: 169.254.169.254, fd00:ec2::254

Three ways to enable (priority order):
1. HERMES_ALLOW_PRIVATE_URLS=true env var
2. security.allow_private_urls: true in config.yaml
3. browser.allow_private_urls: true (legacy, now promotes globally)

Files changed:
- tools/url_safety.py: _global_allow_private_urls() with cached config
  read, _ALWAYS_BLOCKED_IPS for metadata endpoints, is_safe_url() checks
  the toggle after blocking metadata hostnames
- hermes_cli/config.py: security.allow_private_urls in DEFAULT_CONFIG
- tests/tools/test_url_safety.py: 32 new tests covering toggle, config
  fallback, caching, and security invariants (metadata always blocked)
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #14166 — your commit was cherry-picked onto current main with authorship preserved in git log. Had to drop the stray pr-body.md file, everything else is identical. Thanks for the thorough test coverage on the always-blocked metadata endpoints and the link-local /16 hardening — that's the right shape for this fix. Live-verified against @keri's OpenWrt setup that reported the issue.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/tools Tool registry, model_tools, toolsets P2 Medium — degraded but workaround exists tool/browser Browser automation (CDP, Playwright) tool/web Web search and extraction type/security Security vulnerability or hardening

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants