Start with the article
This page is the working reference behind The Validator Can Lie: the cheatsheet, the raw measurements, and the parts that did not fit into a narrative. Read that article first. It carries the argument and the product case studies, and this page assumes them.
If you only want one thing from this page, it is the cheatsheet directly below: what fourteen URL parsers actually do with the same crafted input, at pinned versions. Everything after it is the method, the taxonomy and the dead ends.
The cheatsheet
One crafted input goes to every library at the version pinned below. Each block is what they returned as the host. Where the lines disagree is where a guard and a fetcher can disagree.
Pinned versions
Go net/url go1.26.4
Go fasthttp go1.26.4
Go whatwg-url go1.26.4
Py urlsplit 3.12.13
Py yarl 1.24.2
Node URL 22.22.3
Node url.parse 22.22.3
Java URI 21.0.11
Java URL 21.0.11
Rust url url@2.5.8
.NET Uri 8.0.27
PHP parse_url 8.3.31
Ruby URI 3.3.11
Ruby addressable 2.9.0
Backslash before the @
The changedetection.io shape. Three answers from fourteen parsers.
https://safe.local\@evil.local/
----------------------------------------------------------------
REJECT Go net/url, Py yarl, Java URI, Java URL,
.NET Uri, Ruby URI
evil.local Go fasthttp, Py urlsplit, PHP parse_url,
Ruby addressable
safe.local Go whatwg-url, Node URL, Node url.parse,
Rust url
Percent-encoded backslash
The negative control: encoding it makes every parser agree again.
https://safe.local%5C@evil.local/
----------------------------------------------------------------
evil.local Go net/url, Go fasthttp, Go whatwg-url, Py
urlsplit, Py yarl, Node URL, Node
url.parse, Java URI, Java URL, Rust url,
.NET Uri, PHP parse_url, Ruby URI, Ruby
addressable
Multiple @ in authority
http://a:b@safe.local@internal.local/
----------------------------------------------------------------
internal.local Go net/url, Go whatwg-url, Py urlsplit, Py
yarl, Node URL, Node url.parse, Rust url,
PHP parse_url, Ruby addressable
REJECT Go fasthttp, .NET Uri, Ruby URI
a:b@safe.local@inter Java URI
(empty) Java URL
Encoded dot in the host
http://safe%2elocal/
----------------------------------------------------------------
safe%2elocal Py urlsplit, Py yarl, Java URI, Java URL,
PHP parse_url, Ruby URI, Ruby addressable
REJECT Go net/url, Go fasthttp, .NET Uri
safe.local Go whatwg-url, Node URL, Rust url
safe Node url.parse
Scheme-relative
//evil.local/path
----------------------------------------------------------------
evil.local Go net/url, Go fasthttp, Py urlsplit, Py
yarl, Java URI, .NET Uri, PHP parse_url,
Ruby URI, Ruby addressable
REJECT Go whatwg-url, Node URL, Java URL, Rust
url
(empty) Node url.parse
Backslashes after scheme
http:\\evil.local/path
----------------------------------------------------------------
(empty) Go net/url, Go fasthttp, Py urlsplit, Java
URL, PHP parse_url, Ruby addressable
evil.local Go whatwg-url, Node URL, Node url.parse,
Rust url, .NET Uri
REJECT Java URI, Ruby URI
:80 Py yarl
Three slashes
http:///evil.local/path
----------------------------------------------------------------
(empty) Go net/url, Go fasthttp, Py urlsplit, Node
url.parse, Java URI, Java URL, Ruby URI,
Ruby addressable
evil.local Go whatwg-url, Node URL, Rust url
REJECT .NET Uri, PHP parse_url
:80 Py yarl
IPv4 as hex
http://0x7f000001/
----------------------------------------------------------------
0x7f000001 Go net/url, Go fasthttp, Py urlsplit, Py
yarl, Node url.parse, Java URI, Java URL,
PHP parse_url, Ruby URI, Ruby addressable
127.0.0.1 Go whatwg-url, Node URL, Rust url, .NET
Uri
IPv4 as decimal
http://2130706433/
----------------------------------------------------------------
2130706433 Go net/url, Go fasthttp, Py urlsplit, Py
yarl, Node url.parse, Java URI, Java URL,
PHP parse_url, Ruby URI, Ruby addressable
127.0.0.1 Go whatwg-url, Node URL, Rust url, .NET
Uri
IPv4 as octal
http://0177.0.0.1/
----------------------------------------------------------------
0177.0.0.1 Go net/url, Go fasthttp, Py urlsplit, Py
yarl, Node url.parse, Java URI, Java URL,
PHP parse_url, Ruby URI, Ruby addressable
127.0.0.1 Go whatwg-url, Node URL, Rust url, .NET
Uri
IPv4-mapped IPv6
http://[::ffff:127.0.0.1]/
----------------------------------------------------------------
::ffff:127.0.0.1 Go net/url, Py urlsplit, Py yarl, Node
url.parse, Java URI, Java URL, .NET Uri,
PHP parse_url, Ruby URI, Ruby addressable
::ffff:7f00:1 Node URL, Rust url
[::ffff:127.0.0.1] Go fasthttp
[::ffff:7f00:1] Go whatwg-url
Trailing dot, mixed case
https://Safe.Local./
----------------------------------------------------------------
safe.local. Go fasthttp, Go whatwg-url, Py urlsplit,
Py yarl, Node URL, Node url.parse, Rust
url, .NET Uri
Safe.Local. Go net/url, Java URI, Java URL, PHP
parse_url, Ruby URI, Ruby addressable
Ideographic full stop
http://safe。local/
----------------------------------------------------------------
safe。local Go net/url, Go fasthttp, Py urlsplit, Java
URI, Java URL, .NET Uri, PHP parse_url,
Ruby addressable
safe.local Go whatwg-url, Py yarl, Node URL, Node
url.parse, Rust url
REJECT Ruby URI
Null byte in the host
http://safe.local%00.evil.local/
----------------------------------------------------------------
safe.local%00.evil.l Py urlsplit, Py yarl, Java URI, Java URL,
PHP parse_url, Ruby URI, Ruby addressable
REJECT Go net/url, Go fasthttp, Go whatwg-url,
Node URL, Rust url, .NET Uri
safe.local Node url.parse
IP classifiers: the special-use gap
Every stdlib classifier below was asked about the same 53 wire-confirmed transition-class addresses: NAT64 well-known and local, CGNAT, IPv4-compatible, SIIT, 6to4, Teredo, IPv4-mapped, and the rest.
A gap is any address the classifier fails to identify as special-use: it either calls it ordinary public space, or refuses to parse it at all. Both leave the caller unprotected, so both count.
classifier version public reject gap
--------------------------------------------------------------------------
Ruby IPAddr 3.3.11 41 5 46
Go net.IP.Is* go1.26.4 40 5 45
Java InetAddress 21.0.11 40 5 45
Node ipaddr.js 2.4.0 41 0 41
.NET IPAddress 8.0.27 40 0 40
Rust std::net 1.95.0 35 5 40
PHP filter_var 8.3.31 30 5 35
Python ipaddress 3.12.13 26 5 31
--------------------------------------------------------------------------
of 53
How to read this
Two libraries on different lines of the same block are a seam. If one of them is your guard and the other is your fetcher, the seam is in your application, and the only question left is whether an attacker can reach it.
Which parser is correct is usually the wrong question. Several of these are defensible readings of a genuinely ambiguous grammar. What matters is whether the component that decides and the component that dials sit on the same line.
Everything reproduces: bash orchestrate.sh all in tinker/oracle/, Docker only,
no manual steps. Version drift is expected, which is why they are pinned.
The quirks a differential cannot see
A differential oracle only reports disagreement, which leaves it blind in two directions. Both of them matter.
Agreement is not safety
These inputs produce no differential at all. Every one of the fourteen parsers returns the same host, and for five of them, that agreed host is the attacker’s. A guard built by comparing two parsers sees consensus and passes.
http://169.254.169.254%5C@example.com/../latest/api/token
all 14 -> example.com
http://safe.local%23@evil.local/
all 14 -> evil.local
http://safe.local%3F@evil.local/
all 14 -> evil.local
http://safe.local@internal.local/
all 14 -> internal.local
https://safe.local%5C@evil.local/
all 14 -> evil.local
The last one is the marquee payload’s own negative control. Every parser agrees, which is why it is safe. It is also why unanimity tells you nothing about the string itself: all it reports is that the parsers behaved the same way.
How strict is each parser
Refusal rate over the same 80 crafted inputs. A low number is a design choice rather than a defect, but a parser that never refuses hands every decision to you.
Ruby URI 26/80 32% ###########
.NET Uri 20/80 25% ########
Go net/url 15/80 19% ######
Java URL 13/80 16% ######
Java URI 11/80 14% #####
Go fasthttp 11/80 14% #####
Py yarl 7/80 9% ###
Rust url 6/80 8% ###
Node URL 6/80 8% ###
Go whatwg-url 6/80 8% ###
Ruby addressable 2/80 2% #
Node url.parse 2/80 2% #
PHP parse_url 1/80 1%
Py urlsplit 0/80 0%
urllib.parse.urlsplit refused nothing at all, and PHP’s parse_url refused
one input in eighty. Both are documented as splitters rather than validators, so
this is honest behaviour, but a guard that treats either as a validator has no
validator.
Lone outliers
Cases where exactly one parser out of fourteen produced its answer. These are the behaviours to remember per library.
Ruby URI (10 lone results)
http://127。0。0。1/
-> REJECT
http://169.254.169.254/
-> REJECT
Node url.parse (8 lone results)
//evil.local/path
-> (empty)
http://%31%32%37%2e%30%2e%30%2e%31/
-> (empty)
Go fasthttp (8 lone results)
http://[0:0:0:0:0:0:0:1]/
-> [0:0:0:0:0:0:0:1]
http://[::ffff:127.0.0.1]/
-> [::ffff:127.0.0.1]
.NET Uri (5 lone results)
http://[::a9fe:a9fe]/
-> ::169.254.169.254
http://[fe80::1%25eth0]/
-> fe80::1
Java URL (5 lone results)
http://a:b@safe.local@internal.local/
-> (empty)
http://a@b@c/
-> (empty)
Py yarl (4 lone results)
http:///evil.local/path
-> :80
http:/evil.local/path
-> :80
Go whatwg-url (3 lone results)
http://[0:0:0:0:0:0:0:1]/
-> [::1]
http://[::ffff:127.0.0.1]/
-> [::ffff:7f00:1]
Java URI (3 lone results)
http://a:b@safe.local@internal.local/
-> a:b@safe.local@internal.local
http://a@b@c/
-> a@b@c
Go net/url (1 lone results)
http://[fe80::1%25eth0]/
-> fe80::1%eth0
What nobody normalises
The other blind spot. Two inputs that name the same host are handed back as two different strings, by almost everyone, so the disagreement is not between parsers, it is between a parser and your allowlist.
A https://safe.local./
B https://Safe.Local./
----------------------------------------------------------------
returns them differently (6 of 14):
Go net/url safe.local. | Safe.Local.
Java URI safe.local. | Safe.Local.
Java URL safe.local. | Safe.Local.
PHP parse_url safe.local. | Safe.Local.
Ruby URI safe.local. | Safe.Local.
Ruby addressable safe.local. | Safe.Local.
folds them together (8 of 14):
Go fasthttp both -> safe.local.
Go whatwg-url both -> safe.local.
Py urlsplit both -> safe.local.
Py yarl both -> safe.local.
Node URL both -> safe.local.
Node url.parse both -> safe.local.
Rust url both -> safe.local.
.NET Uri both -> safe.local.
Host case. RFC 3986 6.2.2.1 makes the host case-insensitive and says a normaliser SHOULD lowercase it.
A http://[::1]/
B http://[0:0:0:0:0:0:0:1]/
----------------------------------------------------------------
returns them differently (9 of 14):
Go net/url ::1 | 0:0:0:0:0:0:0:1
Go fasthttp [::1] | [0:0:0:0:0:0:0:1]
Py urlsplit ::1 | 0:0:0:0:0:0:0:1
Node url.parse ::1 | 0:0:0:0:0:0:0:1
Java URI ::1 | 0:0:0:0:0:0:0:1
Java URL ::1 | 0:0:0:0:0:0:0:1
PHP parse_url ::1 | 0:0:0:0:0:0:0:1
Ruby URI ::1 | 0:0:0:0:0:0:0:1
Ruby addressable ::1 | 0:0:0:0:0:0:0:1
folds them together (5 of 14):
Go whatwg-url both -> [::1]
Py yarl both -> ::1
Node URL both -> ::1
Rust url both -> ::1
.NET Uri both -> ::1
IPv6 compression. RFC 5952 gives one canonical text form; these are the same address.
A http://0x7f000001/
B http://2130706433/
----------------------------------------------------------------
returns them differently (10 of 14):
Go net/url 0x7f000001 | 2130706433
Go fasthttp 0x7f000001 | 2130706433
Py urlsplit 0x7f000001 | 2130706433
Py yarl 0x7f000001 | 2130706433
Node url.parse 0x7f000001 | 2130706433
Java URI 0x7f000001 | 2130706433
Java URL 0x7f000001 | 2130706433
PHP parse_url 0x7f000001 | 2130706433
Ruby URI 0x7f000001 | 2130706433
Ruby addressable 0x7f000001 | 2130706433
folds them together (4 of 14):
Go whatwg-url both -> 127.0.0.1
Node URL both -> 127.0.0.1
Rust url both -> 127.0.0.1
.NET Uri both -> 127.0.0.1
IPv4 numeric forms. Both are 127.0.0.1 once the resolver sees them. The four
WHATWG-aligned parsers canonicalise at parse time; the other ten hand the literal
string straight through and let the resolver decide later. That split is the F2
handoff in one line: whether your guard sees 0x7f000001 or 127.0.0.1 depends
entirely on which parser you reached for.
If your allowlist holds safe.local and the parser hands you Safe.Local., the
comparison fails open or closed depending on which way you wrote it. That is not
a parser bug. It is a normalisation step nobody agreed to own.
What the measurements show
One full orchestrate.sh all run emits 1855 verdicts over 16 validators × 5 clients across eight languages: SURVIVES=302, BLOCKED=580, AGREE=803, PARSE-REJECT=166, NO-OP=4. Every SURVIVES is backed by a captured wire line in out/capture_*.jsonl, the emitted :authority was observed, not inferred. The tuple diffed at each hop is {validator-host, resolver-IP, emitted :authority, dialed-IP}.
The marquee composition (gap #1)
The changedetection.io shape http://169.254.169.254\@example.com/../latest/api/token, delivered through an F5b redirect, at the redirect hop:
validator (guard parses the redirect target) -> host client -> dialed verdict
------------------------------------------------------------------------------------------------------------------------
fasthttp / urllib.parse / Python urlsplit /… example.com urllib3 169.254.169.254 SURVIVES
whatwg-url / Node WHATWG / legacy url.parse 169.254.169.254 urllib3 169.254.169.254 BLOCKED
net/url / yarl REJECT (\ invalid) urllib3 — PARSE-REJECT
any example.com libcurl / Go net/http example.com / AGREE /
refused BLOCKED
Four (validator, client) pairs SURVIVE. The blocked rows are blocked for an accidental reason: the guard’s own extracted host is itself internal, so the guard catches the request by classifying 169.254.169.254, not by agreeing with its fetcher. In the full run the parse-rejecting set is wider than the two named above: out/prevalence.md §1 also records System.Uri, java.net.URI, java.net.URL and Ruby stdlib URI rejecting the literal backslash.
Four real clients split four ways: urllib3 survives, stdlib urllib errors, Go net/http parse-rejects, libcurl stays safe. That is honest non-uniformity, not a uniform client-side bug.
The %5C@ variant makes every parser re-agree on example.com: 0 survivals, negative-control gate PASS. That is what isolates the defect. The bug is not “backslash is dangerous” and not “urllib3 is dangerous”. It is precisely a validator that reads the backslash as userinfo paired with a fetcher that reads it as a host-terminator. Percent-encode the character and the disagreement disappears, which means the disagreement, not the character: is the vulnerability.
For a guard: your parser choice is the exploit surface. Extracting the host with urllib.parse and fetching with requests is a survivable pair on this input; extracting with net/url is not, because it refuses to hand you a host at all.
The special-use classifier gap (gaps #6/#7)
Across the wire-confirmed F12 corpus (NAT64 well-known and local, CGNAT, IPv4-compat, SIIT, 6to4, Teredo, …), every transition class is allowed as public by 8 of 8 stdlib IP classifiers. Per-classifier gap totals, of 53 addresses:
Ruby IPAddr 46
Java InetAddress 45
Go net.IP.Is* 45
Node ipaddr.js 41
.NET IPAddress 40
Rust std 40
PHP filter_var(NO_PRIV|NO_RES) 35
Python ipaddress 31
Eight of the eight call every transition class public. CGNAT 100.64.0.1 and the NAT64 set are 8/8: no mainstream stdlib is_private-style predicate covers RFC 6598 or the IPv6 transition prefixes. 100.100.100.100, 100.127.255.254, 168.63.129.16, ::ffff:0:7f00:1 and ::ffff:0:a9fe:a9fe are also 8/8. This is the library-level distribution behind the Lemmy, Strapi, Directus, imagor and Gitea product scalps.
One cross-library split surfaces: ::ffff:169.254.169.254 is un-embedded and blocked by Python, Rust, Java, .NET and PHP, but passes as public under Node ipaddr.js and Ruby IPAddr.
For a guard: ip.is_private() is not a deny list. Nothing in any of the eight standard libraries knows that 64:ff9b::a9fe:a9fe or 100.64.0.1 reaches anything. Those prefixes have to be enumerated by hand.
IDN engine divergence (gaps #3/#4)
On the inputs the UTS-46 corpus itself marks 2003≠2008, the 15 engines split cleanly:
input IDNA2003 fold IDNA2008 / UTS46-N
----------------------------------------------------------------------------------------
faß.de fass.de — Python codecs.idna, xn--fa-hia.de — Go, Rust, Node
PHP UTS46-transitional, (domainToASCII+tr46),
Java IDN.toASCII .NET, Ruby, Python idna
ab (ZWNJ) ab — same three xn--ab-j1t — the 2008 engines
Over the whole 6390-row UTS-46 corpus, transitional matches concentrate in the same places: codecs.idna 896, PHP idn_to_ascii(UTS46-transitional) 238, java.net.IDN.toASCII 106; the remaining engines score 0 or 2. Errored counts are high for the strict engines (Python idna, .NET IdnMapping) because the corpus is full of intentionally-invalid registrable inputs, the match and deviation columns carry the signal, not the error rate.
A guard on Java IDN computes fass.de. A fetcher on Go or Rust computes xn--fa-hia.de. Those are different domains, and an attacker registers the one the guard allowlists while the fetcher reaches the other. Footgun bonus: Go idna.Punycode and idna.Registration do not lowercase, so Faß.de → xn--Fa-hia.de.
H2 Host ≠ :authority desync (gap #2)
A raw h2c framer sending :authority=allowed.example with Host=<internal> is accepted, 200, both values exposed, by the Go x/net/http2 origin for all four internal Host targets (169.254.169.254, 10.0.0.1, 100.64.0.1, 127.0.0.1). The synced control is AGREE. 4 of 5 pairs SURVIVE. The origin is 7540-pinned and does not enforce RFC 9113 §8.3.1.
Reach is bounded exactly as the standalone tinker/http2_desync_7540/ lab found: origin libraries, not a forwarding proxy chain. This is a single-node confused deputy, a guard reading :authority and a sink reading Host inside one process, not a proxy-chain smuggle.
Validator host-extraction divergence
Per family, inputs on which the registered URL parsers disagree about the extracted host:
family disagree total
---------------------------------------------------------
wpt 470 809
P14_redirect_reparse 10 15
P06_idn_mapping_and_ignored_chars 6 6
P03_malformed_slashes_special_scheme 5 5
P05_unicode_label_separators 5 5
P08_ipv6_text_and_zone 5 5
P09_trailing_dot_case_default_port 5 6
P01_backslash_at_authority 4 5
P04_percent_encoded_host_delimiters 4 6
P07_ipv4_numeric_forms 4 4
P02_multi_at_userinfo 3 4
P12_control_null_bytes 3 5
Three families disagree on every input in them. “The host” is not a property of a URL string; it is a property of the parser you asked.
Dial-layer splits
SURVIVES verdicts by corpus family and client:
family operator client SURVIVES
------------------------------------------------------------------------------------
P14_redirect_reparse F5b_redirect requests/urllib3 54
P14_redirect_reparse F5b_redirect libcurl 42
P14_redirect_reparse F5b_redirect net/http 42
P14_redirect_reparse F5b_redirect urllib(stdlib) 42
f12 none net/http 24
f12 none requests/urllib3 24
f12 none urllib(stdlib) 24
f12 none libcurl 18
P04_percent_encoded_host_delimiters none requests/urllib3 12
P04_percent_encoded_host_delimiters none urllib(stdlib) 12
P01_backslash_at_authority none libcurl 4
H2_desync H2_desync go-x-net-http2-framer(7540) 4
Per gap rank: #1 → 184 SURVIVES, #6 → 90, #7 → 90, #2 → 4.
No client is uniformly safe. libcurl is the safe client on the marquee redirect shape and the only surviving client on the P01_backslash_at_authority family dialled directly. Picking a “safer” HTTP client moves which inputs survive; it does not remove the class.
Honest empty cells
F14 (Alt-Svc / SVCB) is a PREDICTED operator with zero product scalps. The altsvc_probe emits NO-OP, no dialed client acted on the advertised hint. It is not faked positive.
decodes_vs_routes is taken from the wire-confirmed F12 routability map, never from lab routing: NAT64 well-known says decodes to, CGNAT and NAT64-local say routes to.
The resolve stage runs the Go pure-Go and cgo resolver interfaces side by side plus synthetic NAT64/compat/mapped un-embedding, and its rows are on record in out/resolve_go.jsonl. The run publishes no resolver divergence total. There is no number to quote here, so none is quoted.
Re-running dial and diff yields identical verdicts, modulo the ephemeral source port in dialed_ip and the documented SVCB equal-priority shuffle (gap #10).
The fifteen families
Indexed by the pipeline stage where two components can legally disagree, not by symptom. Tier A/B means confirmed in a shipped product, C a specimen wired in real code, D a mechanism assembled from library APIs with no product behind it. The full derivation, spec anchors and per-product evidence are in the article.
F1: Raw authority boundary split
Two parsers disagree about where the authority ends: backslash, @, malformed
slashes, encoded delimiters. A: five products. changedetection.io reached live
IMDS through it; spring-cloud-gateway turned it into admin auth bypass.
F2: Resolver handoff after safe parse
The parser returns a harmless host; the resolver canonicalises it to loopback or
metadata. A: Mealie approved an A record while the client dialled AAAA; Firefly
III did the same through gethostbyname against Guzzle.
F3: IDN / Unicode normalisation drift
The validator stores Unicode host text; something downstream maps it onto a different DNS name. Two sanctioned ToASCII pipelines exist. C: Misskey stores blocklist entries lowercased but checks them punified, so they never match.
F4: Authority identity split
Validation, DNS, wire authority and cache key disagree about the canonical authority: trailing dot, default port, case. A (partial): tuwunel’s IPv6-bracket mismatch. No clean port or pool case found.
F5: Path normalisation, join, redirect reparse
The verdict is computed before encoded dot-segments or a redirect change the
target. F5b is the redirect half, where the validator is never re-run on
Location:. A: Thumbor at default config; Open WebUI’s Playwright loader
follows 302 with no knob to stop it.
F6: Header-controlled authority after validation
The URL passes, then a caller-controlled Host or X-Forwarded-Host changes what
is trusted or what goes on the wire. A: XWiki poisons trustedDomains
unauthenticated; n8n’s Host header walks past its own SSRF allowlist.
F7: H2 pseudo-header construction split
The app validates a URL; the H/2 client API accepts an opaque :authority or
:path the validator never saw. D: six libraries expose the surface. No
audited product exposed all three required conditions at once.
F8: Mutable builder / accessor TOCTOU
One accessor is validated; a builder, setter or serialiser then emits a different authority. No spec covers the URL-object lifecycle inside a program, the absence is the seam. A: Apache ShenYu overwrites its upstream from an attacker header, wire-confirmed to IMDS credential theft on live EC2.
F9: String-to-byte delimiter injection
High-Unicode survives validation as a String, then a framer narrows it to bytes
and manufactures ASCII delimiters on the wire. D: primitive disclosed to
Node.js. A hunt across seven products found none combining all the conditions.
F10: Deployment-dependent parser backend
Same application, different behaviour under cgo versus pure resolver, or curl versus native. Dropped from the article; kept only so report numbering resolves.
F11: Validate-then-discard-the-pin
The guard resolves and approves; the fetcher re-resolves with nothing binding the two lookups. A: GitLab computes a correct DNS pin and then throws it away. Rebinding is old; the discard-the-pin variant inside modern guard libraries is not.
F12: Address-family / classifier gap
The resolver returns an address family the denylist never covered: NAT64, 6to4, IPv4-mapped, CGNAT. A at default config in Lemmy, Strapi and Directus, plus nine more. The oracle measures the gap at 8 of 8 stdlib classifiers.
F13: TLS server-identity split
The certificate the network trusts is not the host that was approved. Honest
negative: RFC 9525 is tight enough here that thin coverage is spec rigour, not
under-hunting. Three shipped stacks ship cert-off anyway, all bounded: Elixir Gun
QUIC verify => none, Radarr/Sonarr/Lidarr SNI bypass, Jenkins URL Trigger
trust-all since 2013.
F14: Post-validation routing
After validation, the response or DNS steers the next fetch elsewhere via Alt-Svc,
SVCB, ORIGIN or CONNECT-UDP. C in library code: curl honours a hostile
Alt-Svc and carries the original :authority cross-host. No product chains it.
F15: QPACK framing divergence
The H/3 analogue of F7, in the field-section codec rather than the URL. D — .NET’s managed QPACK encoder emits forbidden NUL, CR and LF; picoquic uses raw Huffman bytes when decode fails. The shape that lands it is an H3-to-H1 intermediary. None audited end-to-end.
Scoreboard
Confirmed in a product (tier A/B) F1 F2 F4 F5 F6 F8 F11 F12 = 8
Probe + vendor advisory, no product F9 = 1
Shipped-code specimen, no CVE (C) F3 = 1
Probe-only, forward-looking lab demo F14 F15 = 2
Honest negative (spec-tight, RFC 9525) F13 = 1
Probe-only, SaaS hunt active F7 = 1
Dropped from the article F10 = 1
Eight families have a product behind them. Four do not, and say so. One is a negative result that survives because the spec at that stage is tight enough to make thin coverage evidence rather than absence of effort.
The oracle
A seam map says a gap exists. It does not say who falls into it. This is the instrument that closes that distance: it feeds the same crafted input to N real parsers, classifiers, IDN engines, resolvers and HTTP clients, follows redirect and Alt-Svc hops, and diffs a four-value tuple across every hop. The output is not “this is a GAP”. It is “K of N libraries disagree, here is which, and here is the wire line”.
The per-product scalps in reports/ give instances. This gives the distribution.
The tuple
Every verdict is one comparison of four values at one hop:
{ validator-host , resolver-IP , emitted :authority , dialed-IP }
│ │ │ │
what a guard what DNS what the client what the socket
would extract handed back committed to on actually connected
from this hop for that host the wire to
The third value is the one that matters and the one most tools infer. The oracle does not infer it. The capture sink echoes back {authority, host_hdr, path, peer, nonce} for every request it sees, so the emitted :authority is read off the wire and joined to the hop by an X-Oracle-Nonce header the dial view sets itself. peer is the dialed IP. A hop with no captured block means the client never reached capture at that hop, blocked, or DNS failure. That is data, not a gap.
Five stages
stage libraries on record (8 languages)
-------- ------------------------------------------------------------------
parse Go net/url · fasthttp · whatwg-url ; Python urllib.parse · yarl ;
Node WHATWG-URL · legacy url.parse ; Rust url ; Java URI · URL ;
.NET System.Uri ; PHP parse_url ; Ruby URI · addressable
classify Go net.IP.Is* ; Python ipaddress ; Node ipaddr.js ; Rust
std::net::IpAddr ; Java InetAddress ; .NET IPAddress ;
PHP filter_var ; Ruby IPAddr
idn 15 engines incl. Python idna (2008) · codecs.idna (2003) ;
Java IDN (2003) ; PHP idn_to_ascii UTS46-N/T ; Go idna.Lookup ·
Registration · Punycode ; Rust idna ; Node domainToASCII · tr46 ;
.NET IdnMapping ; Ruby Addressable::IDNA
resolve Go pure-Go vs cgo resolver, + synthetic NAT64 / compat / mapped
un-embed
dial Go net/http ; requests / urllib3 ; stdlib urllib ; libcurl ;
raw h2c framer (gap #2)
Library version pins are read out of each image at run time and carried in every row, so obsoleted-versus-current drift: RFC 7540 against 9113, IDNA2003 against 2008, RFC 6125 against 9525: is measurable rather than asserted.
The lab
lab bridge oracle_oraclenet (100.64.0.0/24, RFC 6598)
┌───────────┐ --dns 100.64.0.2 ┌──────────┐
│ dial view │ ───────────────────▶│ dnsmasq │ every hostname (incl. computed
│ (per lang)│ └──────────┘ IDN A-labels) → capture
│ │ redirect.lab:8081 ┌──────────┐ 302 Location per ?case=
│ hop walk │ ───────────────────▶│ redirect │ + echoes X-Seen-* so hop-0 lands
│ + force │ altsvc.lab:8082 ┌──────────┐ Alt-Svc: h2=":internal"
│ IP-lit → │ ───────────────────▶│ altsvc │
│ capture │ capture.lab:80 ┌──────────┐ logs + ECHOES {authority,
└───────────┘ ───────────────────▶│ capture │ host_hdr, path, peer, nonce}
└──────────┘
The dial view follows each redirect hop manually, so every hop is observed and none is collapsed by the client’s own redirect loop. Hostname targets resolve through the wildcard lab DNS. IP-literal targets are forced onto the capture sink by the view: Python getaddrinfo patch, Go DialContext override, curl --connect-to, while the Host the client committed to is left intact. The bridge sits in RFC 6598 CGNAT space, so 100.64.x targets route natively: a real peer, not a synthetic one.
The verdict record
out/verdicts.jsonl carries one record per input × library-pair × hop:
input, corpus_family, corpus_source
hop, hop_url, operator operator ∈ none | F5b_redirect | F14_altsvc | F14_svcb
pair { validator, client } each "lib@version"
tuple { validator_host, validator_class, resolver_ip,
emitted_authority, dialed_ip }
validator_rerun did a re-validation actually run at this hop
validator_would_have_caught
verdict SURVIVES | BLOCKED | AGREE | PARSE-REJECT | NEGATIVE-CONTROL | NO-OP
divergence [ "validator_host=example.com but
emitted_authority=169.254.169.254" ]
gap_refs, decodes_vs_routes, lib_versions
SURVIVES is the found signal: a validator extracted an allowed host at some hop while the wire shows a disallowed authority or peer, and no re-validation ran. It must be backed by a real captured wire line. BLOCKED means the client refused or the guard’s own host classified internal. NO-OP is the honest empty cell, an operator advertised a hint the client ignored.
decodes_vs_routes is taken from the wire-confirmed F12 routability map, never from lab routing. The verdict says decodes to unless a routable class proves routes to. The well-known NAT64 prefix never routes non-global.
Joins are by id for parse to dial, by nonce for dial to capture, by host string for resolve to dial. No view binary calls another. They communicate only through JSONL on disk, so any one can be re-run in isolation, and adding a library is one file plus one Dockerfile under views/.
Run shape
A full orchestrate.sh all run produces 1855 verdicts over 16 validators × 5 clients. The corpus behind them:
| source | rows | role |
|---|---|---|
payloads.yaml (P01–P15, promoted) | 70 | hand-curated SSRF-shaped core |
WPT urltestdata.json | 888 | WHATWG parse differential + expected-host oracle |
UTS-46 IdnaTestV2.txt | 6390 | IDNA2003-vs-2008-vs-UTS46 ToASCII vectors |
F12 bypass_corpus.txt (+controls) | 54 | wire-confirmed special-use classifier vectors |
| redirect targets (marquee + F12 set) | 13 | F5b operator case table |
corpus/dial_subset.jsonl narrows this to the SSRF-shaped inputs worth the expensive wire stage. The corpus is assembled and hash-verified by corpus/build_corpus.py against corpus/corpus.lock. No cell is filled by analogy: every input × library-pair × hop is individually dialed and captured.
The negative control
A differential oracle that fires on everything measures nothing. The gate is an input that must agree.
The marquee is http://169.254.169.254\@example.com/../latest/api/token delivered through an F5b redirect. It survives on 4 (validator, client) pairs: validators that read the backslash as userinfo and extract example.com, fasthttp, urllib.parse, Python urlsplit, paired with urllib3, which dials 169.254.169.254.
The control is the same input with the backslash percent-encoded: %5C@. Every parser re-agrees on example.com. Zero survivals. The gate holds.
That pairing is what makes the positive mean something. The divergence is not a property of the URL shape in general; it is a property of the raw byte the two libraries disagree about. Encode the byte and the disagreement disappears. A SURVIVES on a NEGATIVE-CONTROL input is not a finding, it is an oracle bug, the record is stamped "oracle_bug":true and flagged loudly.
The same discipline runs through the rest. F14 (Alt-Svc / SVCB) is a predicted operator with zero product scalps, so it emits NO-OP until a client is observed acting on the hint. The H2 desync run carries a synced control that returns AGREE. High IDN error counts on strict engines are noise from a corpus full of intentionally-invalid inputs; the match and deviation columns carry the signal, not the error rate.
Reproduce
cd tinker/oracle && bash orchestrate.sh all
Zero manual steps, Docker-only: Go via golang:latest, every other view via its official image. up → corpus → parse → resolve → dial → diff → down. Stages run individually if the lab should stay up for inspection:
bash orchestrate.sh up corpus parse resolve dial diff
bash orchestrate.sh down
Then:
less out/prevalence.md # family × stage × library-pair matrix + gap tallies
less out/verdicts.jsonl # one record per input × library-pair × hop
less out/capture_*.jsonl # the durable wire-evidence audit log
out/capture_*.jsonl is the lab servers’ own audit log of every observed request, written independently of any client. Re-running dial and diff yields identical verdicts modulo the ephemeral source port in dialed_ip. That was verified, not assumed.
The seam space
The pipeline is fourteen stages long. A URL string enters at stage 1 and wire bytes leave at stage 14, and between them sit thirteen handoffs where one component passes a representation of the request to the next. The spec walk behind the thesis establishes the shape of it: the specs authorise validator/fetcher disagreement at every handoff except TLS identity verification. Nine stages are spec-loose, one is tight, four are mixed.
The catalogue takes that shape and makes it enumerable. One cell is one RFC clause that authorises at least two defensible implementations of the same step. Not a bug, not a payload, a clause. Each cell carries its governing text, its looseness class (MUST/SHOULD/MAY/unspecified/implementation-defined/conflicting-specs/obsoleted-vs-current-drift/non-normative), the bug family it feeds, and a status:
FOUND: live or probe-confirmed.PARTIAL: spec-anchored, half-confirmed. One leg observed, the other assumed.GAP, the spec says the disagreement is permitted; nobody has confirmed it.
Coverage
seam_space/map/catalog.md was generated on 2026-06-08 from the 14-stage enumeration workflow ssrf-seam-space-enumeration. Its header line declares 205 seams at 36 FOUND, 60 PARTIAL, 109 GAP. The fourteen per-stage rows do not sum to that. They sum to 187, and the file contains exactly 187 enumerated cells, one for each row’s arithmetic. The header total is unreconciled; the per-stage rows are what the cells support.
Stage total FOUND PARTIAL GAP
1 URL acquisition + pre-parse 13 1 8 4
2 URI generic parse (component split) 15 6 4 5
3 Authority subparse (user/host/port) 14 3 3 8
4 Host classification (IP / reg-name) 12 7 2 3
5 IDN / Unicode label processing 13 0 4 9
6 Normalisation ladder (3986 §6.2) 14 2 3 9
7 Origin construction 12 3 5 4
8 Name resolution (DNS/hosts/NSS/SVCB) 15 5 2 8
9 Address-set ordering / selection 10 3 3 4
10 Connect / ALPN / TLS handshake 13 0 4 9
11 SNI emission 10 1 5 4
12 TLS identity verification 12 1 6 5
13 Authority emission (Host/:authority) 14 3 6 5
14 Post-validation routing 20 1 6 13
-----------------------------------------------------------------------
sum of rows 187 36 61 90
catalogue header line 205 36 60 109
Thirteen of the 36 FOUND cells sit in stages 2 and 4, the component split and the host classifier. That is the layer Tsai’s 2017 work opened, and it is the layer that has been worked.
Stage 14 holds the most unchecked cells in absolute terms: 13 GAP of 20, against a single FOUND. Stages 5, 6 and 10 hold nine GAP cells each. Stages 5 and 10 hold thirteen cells apiece and zero FOUND, no cell in IDN label processing or in connection establishment has been wire-confirmed. Stage 3 and stage 8 hold eight GAP each.
Part of that is a corpus problem, not a hunting problem. Each stage brief lists the specs it needed and did not have. UTS-46, the actual ToASCII algorithm, the mapping table, the Transitional_Processing/UseSTD3ASCIIRules/CheckBidi flag definitions: is absent from specs/, and it is named as the single most load-bearing missing document for Stage 5. RFC 7301 (ALPN) is absent from Stage 10’s governing set. The WHATWG Infra Standard, which defines the codepoint membership of every Stage-1 strip step, is absent. urltestdata.json, the differential oracle for stages 1-3, is absent. Stage 12 is the one stage the thesis calls genuinely tight, and it still carries 6 PARTIAL and 5 GAP.
The ranked gaps
seam_space/map/gaps.md ranks fifteen GAP cells by spec-looseness × reach × novelty. These are open problems. They are published as open problems.
#1: control-octet wire-injection at authority emission. RFC 9110 §5.5 says field values are octets carrying no CR/LF/NUL; RFC 9112 §2.2 handles bare CR; RFC 3986 §3.3 and §3.2 leave control octets in path and authority unspecified for emission. A CR, LF or NUL: or a re-decoded %0d/%0a, that survived validation inside the path or authority is written into the request line. Stage 1 already has this on the input side: S1.2 and S2.4 record that a WHATWG fetcher deletes interior tab/LF/CR while a non-WHATWG validator splits the authority on them. Neither has a terminal cell to land on. Confirmation means one HTTP client that does not re-sanitise an interior control octet between the parsed URL object and the emitted request, demonstrated on the wire. Looseness class: unspecified: validator and emitter disagree on whether a byte is a value byte or a structural delimiter.
#3: numeral folding beyond fullwidth. UTS-46 §5’s mapping table folds Arabic-Indic digits (U+0660-U+0669), Devanagari digits and enclosed digits (U+2460 onward) to ASCII 0-9. RFC 3986 §7.4 then permits the platform resolver to read the result as an IPv4 literal. A validator running an ASCII-only IPv4 regex on the pre-normalisation host sees a reg-name. The fetcher resolves 127.0.0.1. The map already grants the fullwidth case at S5.12, where UTS-46 deletes soft-hyphen and ZWSP and decomposes fullwidth letters, but S5.12 is PARTIAL, named in the family description and not product-confirmed. The looseness is non-normative: UTS-46 mapping is informational per RFC 5895 §3. Confirmation needs a stack that UTS-46- or NFKC-normalises the host before resolution while validating before that step.
#4: three IDN contracts in one corpus. IDNA2003 (RFC 3490 §3.1, RFC 3491) carries a Nameprep deviation map: ß→ss, ς→σ, ZWJ/ZWNJ deleted, and four codepoints recognised as label separators (U+002E, U+3002, U+FF0E, U+FF61). IDNA2008 (RFC 5890/5891/5892) has no map and no multi-dot recognition. UTS-46 maps by table. The split is deployed as libidn versus libidn2, and as PHP’s INTL_IDNA_VARIANT_2003 versus INTL_IDNA_VARIANT_2008. A guard on IDNA2003 computes fass.example; a fetcher on IDNA2008 computes xn--fa-hia.example. Those are two different attacker-registrable domains. Rank #9 is the separator half of the same split: host。evil.com with U+FF61 is two labels to an IDNA2003 guard and one label to an IDNA2008 guard, which moves the registrable-domain boundary the allowlist is drawn on. Confirmation is a product whose guard and fetcher sit on different library families, plus both registrations.
#6: SVCB ipv4hint under NAT64. RFC 9460 §7.3 makes hint use a MAY and explicitly permits NAT64 synthesis per RFC 7050; §4.2.1 covers DNS64. The internal target arrives inside the DNS response’s SvcParams rather than in an A or AAAA record. This is the highest-confidence entry into the Stage-14 cluster because the receiving half is already wire-confirmed repeatedly as F12, and niquests is confirmed to accept metadata IPs. Reach covers niquests, curl 8.x with HTTPS-RR, and headless browser-engine loaders that inherit DNS HTTPS upgrade. The routing vector itself has zero product tests.
#7: http://0/. RFC 3986 §7.4 plus RFC 1122 §3.2.1.3(a) ‘this-host’ plus Linux inet_aton: bare 0 decodes to 0.0.0.0, and connect() to 0.0.0.0 routes to services on 127.0.0.1. Low novelty, it is on every SSRF cheat sheet. It is listed because S4.9 covers 0.0.0.0/8 as a denylist-enumeration miss and never states the reachability fact, and a map that claims to be exhaustive owes it a cell.
What confirmation looks like
Rank #2 is no longer a gap, and its resolution sets the standard. The seam was the obsoleted-versus-current drift between RFC 7540 §8.1.2.3, which contains no Host/:authority comparison rule at all, and RFC 9113 §8.3.1, which makes divergence SHOULD-malformed. Lab at tinker/http2_desync_7540/. As of 2026-06-10 the prediction fires on origin stacks: Go x/net/http2, Node http2 v26.1.0 and nghttp2 nghttpd (1.12 and 1.52) all accept Host ≠ :authority, and Go and Node expose both values to application code: r.Host and req.authority carry the :authority, the Host header carries the divergent value. That is a single-node confused deputy.
It is also bounded, and the bounds were published with it. hyper-h2 rejects. Apache mod_http2 normalises Host from :authority. No mainstream proxy forwards the divergence: nginx returns 400, and Caddy, HAProxy and Envoy all normalise to :authority on both H2→H2 and H2→H1.1, so the proxy-chained vhost confusion does not assemble from that set. CR/LF on downgrade was a negative: HAProxy rejects at HPACK decode. The forwarding-proxy path and the TLS/ALPN path remain predicted and unconfirmed.
The claim that survives is: obsoleted-versus-current drift between RFC 7540 and RFC 9113, confirmed on real origin libraries, reach limited to single-node applications. Not “all H2”. Every remaining cell in the table is open on those terms.
Where the blade stopped
The standard applied throughout this post: a primitive is not a product bug, a classifier pass is not a routed packet, and two working legs are not a chain. Every item below cleared one of those bars and failed another. They are recorded here because the ones that cleared all three are only meaningful against them.
Three kinds of stop. They are not interchangeable, and collapsing them would be the dishonest move.
1. Mechanisms with no guarded product
The primitive is real and reachable through a shipped library API. No audited application held all the required conditions open at the same time.
F9: charset-boundary delimiter injection. High-Unicode characters survive validation as String/Char; the fetcher narrows them to bytes and manufactures @ . / ? NUL on the wire. Demonstrated in node:http2 (LATIN1 narrowing) and in Haskell String → ByteString. Disclosed to the Node.js project as a primitive (reports/nodejs_http2_reported/). The missing condition is a conjunction: an app must combine (a) explicit node:http2 to a user-controlled authority, (b) validation at the JS string-codepoint layer, and (c) no percent-encoding between user input and the wire. Smithy/AWS SDK v3 (SigV4 self-defence), Nitroping, pass-js, openclaw, Novu, GroundControl and atproto were checked; none holds all three. Most Node applications reach H1 through undici or axios, where (a) never happens. The decision recorded on 2026-05-21 was that this is structural, not under-hunting.
F7: H2 pseudo-header construction split. Probe-lab only: Node H2, OkHttp :authority-from-Host. The usual objection, “no app opts into HTTP/2”, does not hold, because .NET SocketsHttpHandler and Java/Spring stacks auto-negotiate H2 via ALPN, and the attacker supplies the destination. The condition that never assembled is a product that validates a URL and then hands the lower-level H2 API an opaque :authority the validator never saw. The nearest real instance, Radarr/Sonarr/Lidarr, is API-key-bounded and was _skipped.
F14: post-validation routing. Wire-confirmed in real OSS, in lab: curl 8.20.0 honours Alt-Svc: h3="attacker:port" and steers future requests cross-host carrying the original :authority/:path (Docker-confirmed, plus Alt-Svc cache-file injection); lsquic → OpenLiteSpeed converts an absolute-form :path into a forward-proxy request past an allowlisted :authority. Primitive-tier across stacks: niquests routes QUIC to a SVCB/HTTPS-RR ipv4hint/ipv6hint with no loopback, link-local or RFC 1918 filter; .NET 10 Alt-Svc accepts an arbitrary host and a port in 0..2^32; JDK26 AltSvcProcessor; ORIGIN-frame coalescing in Go x/net/http2, OkHttp and JDK; QUIC version-negotiation forgery across quic-go, quiche, msquic, ngtcp2, lsquic and aioquic. No product chains any of these to attacker-controlled URL fetching across a trust boundary. The oracle records this rather than hiding it: the altsvc_probe emits NO-OP, the hint was advertised, no dialled client acted on it. Four NO-OP verdicts out of 1855.
F15: QPACK framing divergence. The .NET 10 managed QPACK encoder (Ascii.FromUtf16) emits RFC 9114 §4.2-forbidden NUL/CR/LF and accepts all of 0x00-0x7F; picoquic/h3zero silently uses raw Huffman bytes as the literal on decode failure; Haskell http3 stores but never enforces SETTINGS_MAX_FIELD_SECTION_SIZE; Gun/Cowlib cow_qpack has a dynamic-table :authority absolute-index bug. The missing condition is a deployment: an H3 forwarding intermediary that re-encodes H3 → H1/H2, which is where a CRLF-emit or raw-byte fallback would smuggle. None audited end-to-end.
F4: authority identity split. Partial only: the tuwunel IPv6-bracket parse mismatch. RFC 3986 §6.2 and RFC 9110 §4.2.3 authorise three different cache keys for one URL, but no audited app pairs a syntax-based-normalising validator with a protocol-based-normalising cache or pool.
H2 Host ≠ :authority desync, the legs exist, the chain does not. Origin libraries accept the divergence: Go x/net/http2, Node http2 v26.1.0 and nghttp2 nghttpd (1.12 and 1.52) all take it, and Go and Node expose both values to application code: four of five crafted pairs SURVIVE. That is a single-node confused deputy. It is not the proxy-chained vhost confusion, because no mainstream proxy forwards the divergence: nginx rejects with 400; Caddy, HAProxy and Envoy all normalise Host ← :authority on H2→H2 and H2→H1.1. hyper-h2 rejects outright; Apache mod_http2 normalises. Two working legs, no chain.
Two candidates refuted outright. iFLYTEK Astron Agent’s UrlCheckTool: both the IDNA2003-vs-raw-fetch and null-host seams failed end-to-end under a verbatim-real-code harness, the checkUrlForIPv6 null-host throw fires before the blacklist-skip; zero victim hits. Mastodon is F5-clean by architecture, because check_private_address runs per socket open in app/lib/request.rb. Misskey’s IDN store-vs-check split (update-meta.ts:250 lowercases, UtilityService.ts:52-54,117-118 punycodes) is real in shipped code and harness-confirmed, but it lands in the federation blocklist, not in the SSRF guard: the separate post-DNS isPrivateIp check is not bypassed. Blocklist evasion, not a routed internal packet.
2. Spec-tight stages, a different kind of negative
The first list is about hunting. This one is about the standard. At TLS server-identity verification the RFC does not authorise the seam, so thin coverage is signal about the spec, not about effort.
RFC 9525 (2023) is the tight document in the corpus. §6.1 normatively constructs reference identifiers from URL/IRI input; §6.3 restricts wildcards to a single leftmost label; §6.4 mandates octet-for-octet IP-in-SAN matching; §6.6 requires automated clients to hard-fail. Its one loose clause, §7.4 on classifier drift, lands at the F12 surface: host classification, not at the cert-match step.
stage cells FOUND PARTIAL GAP
S10 connection establishment / ALPN / TLS 13 0 4 9
S11 SNI emission 10 1 5 4
S12 TLS identity verification (cert vs ref-id) 12 1 6 5
TOTAL (14 stages) 205 36 60 109
What ships broken at this stage is not a seam but an opt-out. Three shipped stacks turn verification off: Radarr/Sonarr/Lidarr accept an untrusted self-signed cert (chain and name mismatch both) from a plain https://localhost:PORT/ URL via an SNI=localhost / 127.0.0.1 auto-bypass (_skipped, API-key-bounded); the Jenkins URL Trigger plugin installs an unconditional trust-all SSLContext and an always-true hostname verifier (URLTrigger.java:404-434, present since HTTPS support was added in 2013, no opt-in), wire-confirmed end-to-end in a real Jenkins and retained unreported as MITM-bounded CWE-295; Elixir Gun’s gun_quicer:connect/2 ships verify => none over QUIC, acknowledged by an in-tree @todo. All three are bounded to MITM or loopback positions. None is a validator/fetcher disagreement. The honest qualifier on “tight” is RFC 6125: CN-ID fallback and partial wildcards are MAY in 6125 and MUST-ignore in 9525, and both texts are still deployed. That drift is verifiable, and it is the one place the carve-out is soft.
RFC 9525 §7.2 also hedges that URL-vs-URI parser divergence “is not expected” to manifest in cert matching. That hedge is unverified here. It is an assumption in the spec, not a measurement.
3. Bytes that did not become routes
An address that decodes to something internal has not sent a packet. The F12 corpus is 53 wire-confirmed addresses, every one of them observed being allowed as public by a real classifier. Twenty of them route. Thirty-three only decode.
class prefix n verdict missing routing precondition
NAT64 well-known 64:ff9b::/96 10 decodes-only a translator willing to translate a non-global
IPv4 target; RFC 6052 §3.1 forbids it, so
link-local and loopback time out on real clouds
NAT64 local-use 64:ff9b:1::/48 3 routes operator-deployed NSP (RFC 8215) — present
CGNAT 100.64.0.0/10 4 routes none; literal IPv4 on a CGNAT network
IPv4-compatible ::/96 5 decodes-only deprecated by RFC 4291 §2.5.5.1; nothing forwards it
SIIT ::ffff:0:0:0/96 5 decodes-only a SIIT translator on path (RFC 7915 / 6145)
6to4 2002::/16 7 decodes-only a 6to4 relay; the prefix embeds a *public* IPv4
by construction (RFC 3056)
Teredo 2001::/32 4 decodes-only a Teredo relay (RFC 4380)
IPv4-mapped ::ffff:169.254.169.254 decodes-only un-embedded and blocked by Python, Rust, Java,
.NET and PHP; allowed only by Node ipaddr.js
and Ruby IPAddr
Azure WireServer 168.63.129.16 1 routes Azure fabric — present on Azure
::ffff:168.63.129.16 1 decodes-only same host, embedded form, no translator
controls (loopback, 0.0.0.0, 0, RFC 1918,
169.254.169.254, [::1], dword/hex/octal/short) 12 routes none
64:ff9b::a9fe:a9fe is allowed by 8 of 8 stdlib IP classifiers: Ruby IPAddr, Java InetAddress, Go net.IP.Is*, Node ipaddr.js, .NET IPAddress, Rust std, PHP filter_var(NO_PRIV|NO_RES), Python ipaddress. That is a unanimous classifier pass and, absent a NAT64 gateway configured against the well-known prefix, zero packets. The product scalps that landed, Lemmy, Strapi, Directus, landed on the routing rows: CGNAT 100.64.0.0/10 and the bare-0 form, not on the decoding ones. The article’s tables say “decodes to”, never “routes to”, for the 33.
Two further non-deliveries, recorded the same way. The %5C@ negative control makes every registered parser re-agree on example.com: 0 survivals across the same validator × client pairs that yield 4 survivals for the literal backslash. And of 1855 wire-backed verdicts, 302 SURVIVE, 580 are BLOCKED, 166 are PARSE-REJECT: net/url and yarl refuse the backslash outright, and 4 are NO-OP.
What would move an item out of this section
- F9: one application combining explicit
node:http2to a user-controlled authority, codepoint-level validation, and no percent-encoding before the wire. A HaskellString→ByteStringequivalent counts equally. Seven ecosystems were checked and did not have it. - F7, a .NET or Spring application that validates a URL and then passes an opaque
:authorityor:pathto the lower H2 API, without an API key bounding the reach. Removing that bound from the Radarr-class instance graduates it. - F14: one product, in at least one language, chaining Alt-Svc, SVCB/HTTPS-RR, ORIGIN or CONNECT-UDP to attacker-controlled URL fetching across a trust boundary. The measurable trigger already exists: the
altsvc_probeverdict flipping from NO-OP to SURVIVES on any dial client. - F15, an audited H3 forwarding intermediary that re-encodes to H1 or H2, where the .NET NUL/CR/LF emit or the picoquic raw-byte fallback reaches the downstream parser.
- F4, an application whose validator performs syntax-based normalisation while its cache key or fetcher performs protocol-based normalisation, per RFC 9110 §4.2.3.
- H2 desync, a forwarding proxy that carries
Host≠:authorityrather than normalising it. nginx, Caddy, HAProxy and Envoy all refuse; a CDN, service mesh, or older proxy that does not would complete the chain. - F13, a cert-identity failure that is neither MITM- nor loopback-bounded, or a 6125-pinned verifier reachable from an attacker-supplied URL inside a product. That would convert an insecure default into a seam, and would narrow the spec-tight claim.
- The 33 decodes-only rows, a deployment where the translator exists: a NAT64 gateway configured with a non-well-known NSP, a SIIT translator, a 6to4 or Teredo relay on path. The bytes do not change. The network does. Then the same input that decodes becomes an input that routes, and it belongs in a different section.