Skip to content

fix(security): add SSRF protection to vision_tools and web_tools (hardened)#2679

Merged
teknium1 merged 3 commits intomainfrom
hermes/hermes-28b19313
Mar 23, 2026
Merged

fix(security): add SSRF protection to vision_tools and web_tools (hardened)#2679
teknium1 merged 3 commits intomainfrom
hermes/hermes-28b19313

Conversation

@teknium1
Copy link
Copy Markdown
Contributor

Summary

Salvage of PR #2630 by @dieutx with security hardening on top.

Original contribution (cherry-picked with authorship preserved):

  • New tools/url_safety.py module with is_safe_url() — resolves hostnames via DNS and blocks private/internal IP ranges
  • Integration into vision_tools.py, web_tools.py (extract + both crawl paths)
  • 13 tests in test_url_safety.py, updated existing vision test

Hardening additions:

Issue Fix
Fail-open on DNS errors and exceptions Changed to fail-closed (OWASP best practice)
CGNAT range (100.64.0.0/10) not blocked Added explicit check — is_private returns False for this range
Multicast (224.0.0.0/4) not blocked Added is_multicast and is_unspecified checks
Redirect-based SSRF bypass in vision_tools Added httpx event hook that re-validates each redirect target
Parallel/Tavily extract paths unprotected Moved SSRF filter before backend dispatch
DNS rebinding (TOCTOU) Documented as known limitation (requires connection-level fix)

Verified:

  • Live PTY testing: web_extract + vision_analyze work with public URLs, correctly block localhost/169.254.169.254
  • Full test suite: 6044 passed, 0 failed
  • Alternative IP encodings (decimal, hex, octal, shortened) all caught by getaddrinfo normalization
  • IPv4-mapped IPv6 (::ffff:127.0.0.1) correctly blocked on Python 3.13

Test plan

  • 48 new/updated SSRF-specific tests (24 in test_url_safety.py, mocks in test_vision_tools.py + test_website_policy.py)
  • Parametrized _is_blocked_ip tests covering 18 blocked and 6 allowed IPs
  • All existing tests updated to mock DNS for test hostnames (fail-closed changes would otherwise block them)
dieutx and others added 3 commits March 23, 2026 15:22
Both vision_analyze and web_extract/web_crawl accept arbitrary URLs
without checking if they target private/internal network addresses.
A prompt-injected or malicious skill could use this to access cloud
metadata endpoints (169.254.169.254), localhost services, or private
network hosts.

Adds a shared url_safety.is_safe_url() that resolves hostnames and
blocks private, loopback, link-local, and reserved IP ranges. Also
blocks known internal hostnames (metadata.google.internal).

Integrated at the URL validation layer in vision_tools and before
each website_policy check in web_tools (extract, crawl).
The existing test_valid_url_with_port asserted localhost URLs pass
validation. With SSRF protection, localhost is now correctly blocked.
Update the test to verify the block, and add a separate test for
valid URLs with ports using a public hostname.
…, redirect guard

Follow-up hardening on top of dieutx's SSRF protection (PR #2630):

- Change fail-open to fail-closed: DNS errors and unexpected exceptions
  now block the request instead of allowing it (OWASP best practice)
- Block CGNAT range (100.64.0.0/10): Python's ipaddress.is_private
  does NOT cover this range (returns False for both is_private and
  is_global). Used by Tailscale/WireGuard and carrier infrastructure.
- Add is_multicast and is_unspecified checks: multicast (224.0.0.0/4)
  and unspecified (0.0.0.0) addresses were not caught by the original
  four-check chain
- Add redirect guard for vision_tools: httpx event hook re-validates
  each redirect target against SSRF checks, preventing the classic
  redirect-based SSRF bypass (302 to internal IP)
- Move SSRF filtering before backend dispatch in web_extract: now
  covers Parallel and Tavily backends, not just Firecrawl
- Extract _is_blocked_ip() helper for cleaner IP range checking
- Add 24 new tests (CGNAT, multicast, IPv4-mapped IPv6, fail-closed
  behavior, parametrized blocked/allowed IP lists)
- Fix existing tests to mock DNS resolution for test hostnames
@teknium1 teknium1 merged commit 0791efe into main Mar 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants