One URL, Two Hosts: Patching the Parser Gap in Hermes' SSRF Guard


Someone drops a link in your DM and you hand it to your agent: fetch this, skim it, tell me if it matters. It works, the summary arrives, nothing looks strange. Behind that unremarkable success, two pieces of code disagreed about what the link pointed at — the check that decides “is this address allowed” and the client that actually opened a socket. The check thought it was a public IP. The browser read the same characters as a port on your own machine. PR #145578, merged October 27, closes that gap: when a URL has two plausible readings, Hermes now refuses it instead of picking one.

The check and the connection were reading different strings

Before Hermes fetches anything, it screens the destination: loopback, private ranges and cloud metadata endpoints are supposed to be off limits. That logic lives in tools/url_safety.py, with is_safe_url() as the main decision maker and is_always_blocked_url() as a blunter backstop.

The screening code parses the URL with Python’s urllib.parse — specifically urlsplit. The thing that eventually opens the connection often does not. The browser tool sits on Chromium. Node-based fetch backends (a self-hosted Firecrawl, for instance) and urllib3 each follow their own conventions. Browsers follow the WHATWG URL standard. Feed the same string to both stacks and you can get two different hosts — and the host is the only thing the security check looks at.

Four URLs, two answers

The PR ships four cases you can check against directly:

URL Host per urlsplit / httpx Host per WHATWG Result on main
http://127.0.0.1:8080\@1.1.1.1 1.1.1.1 127.0.0.1:8080 is_safe_url returns True
http://%31%32%37.0.0.1/ %31%32%37.0.0.1 127.0.0.1 DNS-dependent (True behind a proxy when DNS fails)
http://***@1.1.1.1/ 1.1.1.1 169.254.169.254 is_always_blocked_url returns False
http:\\169.254.169.254\ none 169.254.169.254 is_always_blocked_url returns False

The first row is the clearest illustration. urlsplit sees the @, decides everything before it is a credential pair, and reports the host as 1.1.1.1 — public, fine, go ahead. Chromium ends the authority at the backslash and cheerfully connects to 127.0.0.1:8080. An address that passed as “on the internet” lands on a loopback port.

The second row is a percent-encoded host. The check sees the literal text %31%32%37.0.0.1; the browser decodes it to 127.0.0.1. On main the verdict even depends on DNS: with a proxy configured and resolution failing, the function returns True.

Rows three and four take a different route. is_always_blocked_url() did not block either of them — and that is the function the paths which skip is_safe_url() rely on: the local Chromium backend and the hybrid sidecar. The last line of defence for metadata addresses had the same hole.

Where the two readings diverge

WHATWG does three things urlsplit does not:

  • Backslash is a slash. For special schemes like http, a backslash is equivalent to /, and the authority ends at the first one. So the authority in http://127.0.0.1:8080\@1.1.1.1 is just 127.0.0.1:8080.
  • The host gets percent-decoded. %31%32%37 is 127.
  • Missing slashes get fixed up. http:\\host and http:///host both normalise to http://host.

urlsplit does none of that. It does not treat backslash specially, does not decode the host, and treats a missing // as “there is no authority here at all.” Which means “is this URL pointing inside my network?” has no single answer — it depends who you ask. The safety check asked one stack; the client used another. The space between them is the bypass.

The fix: refuse ambiguity instead of resolving it

Rather than teach one parser to imitate the other, the patch takes the opposite route: if two stacks could read different hosts out of the same string, treat it as unsafe. The term for this is fail closed — reject an address that might have been fine, rather than guess which reading the client will use.

That lives in a new helper, _authority_ambiguity(), at line 253 of tools/url_safety.py. For http(s) URLs it strips ordinary leading and trailing whitespace (space, tab, CR, LF, VT, FF) and then rejects the URL if any of the following holds:

  • A C0 control character or DEL appears anywhere — including at the end, as in http://169.254.169.254\x01, because urlsplit silently drops tabs, CR and LF, so the same bytes can mean different things on the two sides.
  • The // authority is missing: http:\\host, http:///host, http:host.
  • A backslash appears anywhere in the authority.
  • The authority contains @ (userinfo, which is_safe_url() already rejected outright).
  • The host contains % or whitespace.
  • The port is invalid — non-numeric or out of range.

is_safe_url() calls it right after the scheme check. When it rejects, the log records the reason only, never the URL — URLs can carry credentials, and a log file is just another place to leak them.

Why the fallback check got looser, not stricter

is_always_blocked_url() (line 342) serves the backends that never call is_safe_url(): local Chromium, and the hybrid sidecar. It rejects only what can make a client reach a different host: control characters, a missing // authority, a backslash in the authority. Percent-encoded hosts are judged after decoding, so http://%31%36%39.254.169.254/ is caught.

Invalid ports, whitespace in the host, and userinfo do not change the host the client ends up talking to, so the fallback deliberately ignores them. The reason is concrete: browser_exec builds its own URLs from template literals — f"http://localhost:{port}/", "http://localhost:%d/", and a JS `http://localhost:${port}/api` — and a strict fallback would break all of them on the local browser backend. Strictness stays where it belongs, in is_safe_url(); the fallback guards the one property that matters, namely that the host cannot be read two ways.

One more detail that is easy to miss: both functions take the host from the same already-stripped string they used for the control-character check, then compare the IDNA-encoded host against the metadata address after mapping U+3002, U+FF0E and U+FF61 to .. The practical effect is that http://169.254.169.254。/, forms built with full-width or half-width dots, and full-width digits all resolve to 169.254.169.254 for this purpose — even when DNS fails and a proxy is configured.

Non-http(s) URLs such as about: and data: are untouched by the change.

Two concepts worth having straight

SSRF — server-side request forgery. You ask a server to fetch a URL on your behalf; an attacker crafts that URL so the server reaches something they cannot reach themselves: a loopback port, an internal admin panel, a cloud metadata API. Your service becomes their proxy and their foothold inside the network. What makes it nasty is that the request really does originate from inside, so perimeter rules have nothing to object to — the caller is, by definition, “one of us.”

169.254.169.254 is the link-local address cloud providers expose on every instance as a metadata service. It serves more than hostname and region; typically it also hands out instance-level temporary credentials. Anyone who can trick a server into requesting that address may walk away with the machine’s cloud identity, and what that identity can do depends on the role attached to the instance. That is why it is handled as a separate, never-allowed class of address rather than folded into the general private-range check.

Stack the two together and you have this bug: a URL that convinces the checker it points at the public internet, and a host that the client reads as loopback or as metadata.

What to do with this

  • Stay current. hermes update. The change landed on main on October 27, and the PR explicitly notes there is no associated issue — this is proactive hardening, not a patch for a known exploit in the wild.
  • Expect the new semantics: ambiguous means rejected. If you have automation that deliberately builds clever URLs of this shape, it will now be refused. That is the intent, not a regression; ordinary URLs are unaffected.
  • The pattern generalises. The earlier fix that established loopback is not a trust boundary for the A2A port runs on the same idea: a security decision is only as good as its agreement with the component that actually performs the action.
  • If you care about the surfaces an agent can reach outward, read the MCP catalog security guidance alongside the browser extension control surface — the first is about what you install, the second about how much authority the browser side holds. This bug sat precisely where those two meet.

The lesson, if you want one line: the host your validator reads has to be the host your client dials. When that cannot be guaranteed, failing closed is the only honest answer.