SSRF Prevention Cheat Sheet: URL parser differentials are not covered

Author: tamerkallaCreated Sep 3, 2026Updated Sep 7, 2026
LabelsACK_OBTAINEDUPDATE_CS

http://example.com\@evil.com is example.com to JavaScript and evil.com to Python.

$ node -e "console.log(new URL('http://example.com\\\\@evil.com').hostname)"
example.com

$ python3 -c "from urllib.parse import urlsplit; print(urlsplit('http://example.com\\\\@evil.com').hostname)"
evil.com

Both are correct. WHATWG URL treats a backslash as a path separator in a special scheme, so the authority ends at the backslash. RFC 3986 treats it as an ordinary character, so the authority runs to the last @ and the userinfo is example.com\. Neither standard is going to adopt the other's rule.

Why this matters for the SSRF Prevention Cheat Sheet

The cheat sheet's application-layer guidance is, in effect: parse the URL, extract the host, check it against an allowlist, then make the request. That is sound when one parser does both the checking and the fetching. It is not sound when the URL crosses a service boundary as a string, validated in one runtime and fetched by another. That is the common shape in a gateway, a webhook dispatcher, a redirect follower, or any polyglot service mesh.

In that shape the allowlist check and the request can disagree about which host the URL names, and neither component is buggy. The current text does not mention this case, so a reader following it closely can still build a bypassable allowlist.

How wide the gap is

Over a deterministic enumeration of 312 URLs (2 schemes x 26 authority constructions x 6 path suffixes, no sampling) the WHATWG reading and the RFC 3986 reading name a different host on 84 of them. Seven distinct authority constructions account for those 84, and each one works differently:

authority WHATWG reads RFC 3986 reads mechanism
example.com\@evil.com example.com evil.com backslash is a slash in a special scheme
0x7f.1 127.0.0.1 0x7f.1 hexadecimal IPv4 canonicalisation
017700000001 127.0.0.1 017700000001 octal IPv4 canonicalisation
2130706433 127.0.0.1 2130706433 decimal IPv4 canonicalisation
①.com 1.com ①.com UTS-46 mapping
exаmple.com (Cyrillic а) xn--exmple-4nf.com exаmple.com IDNA to ASCII
example。com (U+3002) example.com example。com ideographic full stop separates labels

The numeric-IPv4 rows run the opposite way from the backslash row, which is worth noting explicitly: http://2130706433 is 127.0.0.1 to a private-range filter written against new URL(), and an opaque hostname to anything RFC-derived. So the mismatch can defeat a denylist as readily as an allowlist, depending on which side does the blocking.

A further 48 rows across four constructions are acceptance differences, where one convention refuses the URL outright and the other returns a host: exa mple.com, example.com:65536, example.com:+80, example.com: 80.

Measured with Node 22.22.2 and CPython 3.11.15.

Suggested addition

A short subsection under the application-layer defences, roughly:

Do not let two parsers disagree. If a URL is validated by one component and fetched by another, especially across languages, validate and fetch using the same parse result, not the same URL string. Pass the resolved host, or a re-serialised URL, rather than the original input. Where that is not possible, reject any URL whose host is not read identically by the conventions in play.

I am happy to open a PR with wording along these lines if that is useful, and to adjust the framing. I did not want to send a documentation PR to this repo without first checking that the gap is one you consider in scope.

Disclosure: while measuring this I packaged the check as https://www.npmjs.com/package/host-consensus, so the numbers above are reproducible from a clean install.