← xclow3n Poster 08 RESEARCH
08 14 parsers, pinned Featured

The URL Parser Cheatsheet

What fourteen URL parsers actually do with the same crafted input, at pinned versions, plus the oracle behind it. Fifteen bug families indexed by pipeline stage, a differential oracle that feeds one crafted input to sixteen validators and five clients across eight languages, 1855 wire-backed verdicts, the seam cells nobody has checked, and an honest account of every mechanism that never reached a shipped product.

Contents · 8 sections
  1. 01Start with the article
  2. 02The cheatsheet
  3. 03The quirks a differential cannot see
  4. 04What the measurements show
  5. 05The fifteen families
  6. 06The oracle
  7. 07The seam space
  8. 08Where the blade stopped

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
a‌b (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ß.dexn--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:

sourcerowsrole
payloads.yaml (P01–P15, promoted)70hand-curated SSRF-shaped core
WPT urltestdata.json888WHATWG parse differential + expected-host oracle
UTS-46 IdnaTestV2.txt6390IDNA2003-vs-2008-vs-UTS46 ToASCII vectors
F12 bypass_corpus.txt (+controls)54wire-confirmed special-use classifier vectors
redirect targets (marquee + F12 set)13F5b 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:

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 StringByteString. 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

Back to posters