Hermes No Longer Fetches Whatever URL Someone Handed It

You run an image-generation service on the little server in your hallway and point a provider base_url at it. One day the agent generates a picture, and the “image URL” it gets back reads http://127.0.0.1:8080/admin/... — and Hermes dutifully fetches it and caches it as an image. Or take the worse script: the result URL points at 169.254.169.254, the cloud instance-metadata service, and the machine’s temporary IAM credentials take a walk down a path that looks exactly like downloading a picture.
These URLs share one property: you did not choose them. They arrive from a provider’s response, a field the model filled in itself, or an entry in a third-party index — the canonical attack surface. PR #114887, merged on 18 September 2026, connects seven such fetch sites to the SSRF guard the repository already had.
The shape of the problem: seven fetch sites still using raw clients
Hermes already had URL safety checks (tools/url_safety.py), used by platform media downloads, the web tools and the browser paths. But seven other places called requests.get, httpx.get or urllib.request.urlopen directly, on URLs they had no say about:
agent/provider_media.py::save_url— the result URL a generation provider returns, shared bysave_url_imageandsave_url_video;- the two fetch sites inside the OpenAI and OpenAI-Codex image-generation plugins that turn a remote image into bytes or a data URL;
- the pet store’s
_http_get/_downloadand the petdex manifest fetch; - the TUI gateway’s remote-image-to-data-URL path;
- the skills.sh sitemap catalog fetch.
One door had a lock. Seven windows beside it did not — which is the most common and most instructive failure mode in security work.
The fix: three checks, each harder to slip past than the last
Nothing new was invented. The existing guard was applied in full, and the three easy bypasses were closed:
- Check before the fetch. Every site runs
is_safe_urlfirst; a refused address never opens a connection, and the error names what happened:Blocked request to private/internal address: 127.0.0.1. - Check again at connect time. Clients are built with
create_ssrf_safe_client, which re-applies the policy at TCP connect and dials the validated IP while preserving Host/SNI. This closes the DNS-rebinding (TOCTOU) window: if the first resolution returns a public address and the second returns an internal one, a check-before-fetch-only implementation walks straight into it. - A manual, bounded redirect loop. At most five hops, each one re-validated — and a 3xx without a
Locationheader now fails closed instead of being cached as an image. Caller-supplied headers (say the OpenRouter video bearer key) are scoped to the first hop only, so a redirect to another domain cannot carry them along.
The address classes the guard refuses (from the official documentation):
- Private networks (RFC 1918):
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 - Loopback:
127.0.0.0/8,::1 - Link-local:
169.254.0.0/16, which includes cloud metadata at169.254.169.254 - CGNAT / shared address space (RFC 6598):
100.64.0.0/10— what Tailscale and WireGuard VPNs hand out - Cloud metadata hostnames:
metadata.google.internal,metadata.goog - Reserved, multicast and unspecified addresses
Two defaults set the tone: DNS failures count as blocked (fail-closed), and every hop of a redirect chain is re-validated.
If you genuinely need internal URLs, there are two opt-ins
This is not a blanket ban, and the documentation draws the line carefully:
# Global opt-out (default: false) — an explicit trust-boundary decision
security:
allow_private_urls: true
# Narrower: only the local proxy's own fake-ip block
security:
fake_ip_ranges:
- 198.18.0.0/15
allow_private_urlsfits home networks that resolvehome.arpainto RFC 1918 space, LAN-only Ollama or llama.cpp endpoints, internal wikis. The docs are blunt about what it means: this is a deliberate trust boundary — enable it only where “the agent on this machine can be prompt-injected into hitting the local network” is an acceptable risk, and leave it off on public-facing gateways. One detail worth noting: the host-substring guard that blocks lookalike Unicode domains stays on regardless of this setting.fake_ip_rangesis for TUN proxies in fake-ip mode (Mihomo/Clash fake-ip, Surge enhanced). Those proxies answer every name outside their filter with an address from their own block (198.18.0.0/15by default), so the private-address guard would otherwise reject every outbound fetch with “URL targets a private or internal network address” while the request never reaches the network. Declaring the block lets only that block through; loopback, RFC 1918, link-local, CGNAT and metadata destinations stay blocked. An entry that overlaps any of those classes —0.0.0.0/0or::/0— is ignored with a warning rather than quietly widening the guard.
There is one more exception that matters in daily use: your own provider base_url is not “someone else’s URL”. A download fetched directly from the configured base_url (the OpenRouter video content endpoint is the example) skips the private-address class check on that first hop, but the cloud-metadata floor still applies, and any redirect it issues is re-validated in full. So if you host an image-generation provider on your LAN and want its result URLs cached locally, that is the case security.allow_private_urls: true exists for.
What the PR measured: nine of nine hit before, zero after
The verification is worth quoting because it is the kind that actually proves something. A listener on loopback records every connection, and the real code path drives each call site:
| Scenario | Before | After |
|---|---|---|
http://127.0.0.1:PORT/... at all nine sites |
the listener recorded a hit at every site; bodies were returned and cached | zero hits; each site raised its own error type (_image_to_data_url returns None) with Blocked request to private/internal address: 127.0.0.1 |
a safe first hop, then 302 to 169.254.169.254 |
a real connect attempt | that hop is refused |
a 3xx with no Location header |
the 3xx body was cached as an image | fails closed |
What to do on your own install
Three practical steps, most important first:
- On a public-facing gateway, confirm the opt-out is off. Run
hermes config get security.allow_private_urls; unset means the default, which is off. Leaving it on after a one-off internal-service experiment is a common and very quiet mistake. - If you run image generation or a local model on your LAN, resist flipping the global switch first. If all you need is for a provider’s result URLs to be cached locally, that is the single knob. And if you are behind a fake-ip TUN proxy, prefer the narrower
fake_ip_ranges. - Add common sense on cloud machines. This guard is a door that cannot be walked around, but instance-metadata credentials can also be reached through terminal commands — and that side is handled by an approval gate, which we covered in the 169.254.169.254 approval-gate post. The two together are the complete picture.
Related reading: the MCP catalog credential closed-schema fix (third-party entries must not be able to write arbitrary environment variables) and the official remote MCP catalog review model. The security documentation itself lives at hermes-agent.nousresearch.com/docs/user-guide/security.
Release status
PR #114887 merged into main on 18 September 2026, and the newest tag is still v0.21.3 (tag name v2026.9.14, released 14 September), so it is not in a tagged release yet. A git install picks it up on the next update:
hermes --version
hermes update
Docker image and cloud users get it with the next tag.
The lesson
Boil it down and the rule is this: a guard is only as good as the number of entrances it covers, not how strict it is written. Seven fetch sites were still using raw HTTP clients, and at least two attack classes — DNS rebinding and a Location-less 3xx — defeat the check-before-fetch pattern outright. The robust shape does all three things at once: validate at the entrance, validate again at connect, validate every hop — and give the people who legitimately need internal access an explicit, auditable, as-narrow-as-possible switch.