← xclow3n Poster 08 RESEARCH
08 14 parsers, pinned

The URL Parser Cheatsheet

A pinned comparison of fourteen URL parsers, fifteen bug families indexed by pipeline stage, and a differential oracle covering sixteen validators and five clients across eight languages. Includes 1855 wire-backed verdicts, unchecked cells, and mechanisms that did not reach a shipped product.

Contents · 8 sections
  1. 01How this reference fits
  2. 02The cheatsheet
  3. 03The quirks a differential cannot see
  4. 04What the measurements show
  5. 05The fifteen families
  6. 06The oracle
  7. 07Coverage map
  8. 08Negative results and limits

How this reference fits

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.

The cheatsheet below compares what fourteen pinned URL parsers return for the same crafted inputs. The remainder documents the method, taxonomy, and negative results.

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 input produces 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 disagree. If one is the guard and the other is the fetcher, the application contains that parser boundary; the remaining question is whether attacker-controlled input 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. It cannot identify either unsafe consensus or equivalent values returned in different forms.

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 input is the backslash payload’s 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 expected 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

Normalisation gaps

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

Backslash redirect composition (gap #1)

The changedetection.io input http://169.254.169.254\@example.com/../latest/api/token, delivered through an F5b redirect, produces this result 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, and libcurl stays safe. The clients do not share one failure mode.

The %5C@ variant makes every parser re-agree on example.com: 0 survivals, negative-control gate PASS. This isolates the defect to the pairing of a validator that reads the backslash as userinfo and a fetcher that reads it as a host terminator. Percent-encoding the character removes the disagreement.

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

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ß.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 safe on the backslash redirect but is the only client that survives when the P01_backslash_at_authority family is dialled directly. Changing HTTP clients changes which inputs survive without removing the class.

Negative and empty results

F14 (Alt-Svc / SVCB) is a PREDICTED operator with no product finding. The altsvc_probe emits NO-OP because no dialled client acted on the advertised hint.

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. Negative result: RFC 9525 is strict enough that limited coverage at this stage follows from the specification rather than a lack of target review. Three shipped stacks disable certificate verification, all with bounded impact: 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. Exploitation requires an H3-to-H1 intermediary. None was 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
  Negative result (spec-tight, RFC 9525) F13                         = 1
  Probe-only, SaaS hunt active           F7                          = 1
  Dropped from the article               F10                         = 1

Eight families have a product finding, four do not, and F13 is a negative result at a stage governed by a comparatively strict specification.

The oracle

The coverage map identifies possible gaps but not the affected implementations. The oracle feeds each crafted input to real parsers, classifiers, IDN engines, resolvers, and HTTP clients; follows redirect and Alt-Svc hops; and compares a four-value tuple at every hop. Its output identifies which libraries disagree and includes the corresponding wire record.

The product reports in reports/ give individual instances; this oracle measures 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

Most tools infer the third value, but this oracle measures it. The capture sink echoes {authority, host_hdr, path, peer, nonce} for every request it sees, so the emitted :authority is read from the wire and joined to the hop by an X-Oracle-Nonce header set by the dial view. peer is the dialled IP. A hop without a captured block records that the client was blocked, hit a DNS failure, or otherwise did not reach the capture service.

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 from each image at run time and carried in every row. This makes drift between RFC 7540 and 9113, IDNA2003 and 2008, or RFC 6125 and 9525 measurable rather than assumed.

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 means 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 captured wire line. BLOCKED means the client refused or the guard’s own host classified internal. NO-OP means an operator advertised a hint that 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/.

Corpus and output

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 (backslash + 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 negative-control input checks that the oracle does not report a differential when every parser agrees.

The tested input is http://169.254.169.254\@example.com/../latest/api/token, delivered through an F5b redirect. It survives on four (validator, client) pairs: fasthttp, urllib.parse, and Python urlsplit read the backslash as userinfo and extract example.com; paired with urllib3, the client 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.

The positive depends on the raw byte over which the libraries disagree. Encoding the byte removes the disagreement. A SURVIVES verdict on a NEGATIVE-CONTROL input indicates an oracle bug; the record is stamped "oracle_bug":true and flagged.

F14 (Alt-Svc / SVCB) has no product finding, so it emits NO-OP until a client is observed acting on the hint. The H2 desync run includes a synchronized control that returns AGREE. High IDN error counts on strict engines come from a corpus full of intentionally invalid inputs; the match and deviation columns carry the useful result.

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.

Coverage map

The pipeline has fourteen stages. A URL string enters at stage 1 and wire bytes leave at stage 14, with thirteen handoffs between representations. The specification review found that validator/fetcher disagreement is permitted at every handoff except TLS identity verification. Nine stages are spec-loose, one is tight, and four are mixed.

The catalogue makes those differences enumerable. Each cell represents an RFC clause that permits at least two defensible implementations of the same step. It records the governing text, looseness class (MUST/SHOULD/MAY/unspecified/implementation-defined/conflicting-specs/obsoleted-vs-current-drift/non-normative), related bug family, and 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.

Some of this reflects missing corpus material. Each stage brief lists the specifications it needed but did not have. UTS-46—the ToASCII algorithm, mapping table, and Transitional_Processing/UseSTD3ASCIIRules/CheckBidi flag definitions—is absent from specs/ and is the most important 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, as is urltestdata.json, the differential oracle for stages 1–3. Even Stage 12, the strictest stage in the thesis, carries 6 PARTIAL and 5 GAP cells.

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.

Negative results and limits

Throughout this post, a primitive is not treated as a product bug, a classifier pass is not treated as a routed packet, and two working legs are not treated as a complete chain. The results below met one condition but not another, grouped by where confirmation ended.

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. On 2026-05-21 I stopped the product search after those structural constraints held across the reviewed targets.

F7: H2 pseudo-header construction split. Probe-lab only: Node H2, OkHttp :authority-from-Host. .NET SocketsHttpHandler and Java/Spring stacks auto-negotiate H2 via ALPN, so explicit application opt-in is not required. What I did not find was a product that validates a URL and then gives 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 altsvc_probe therefore emits NO-OP: the hint was advertised, but no dialled client acted on it. Four of the 1855 verdicts are NO-OP.

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 previous group records product-search limits. At TLS server-identity verification, the RFC does not permit the same validator/fetcher disagreement, so the limited coverage reflects a stricter specification.

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

The deployed failures at this stage are explicit opt-outs rather than validator/fetcher disagreements. Three shipped stacks disable verification: 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. RFC 6125 is the qualification on the “tight” label: CN-ID fallback and partial wildcards are MAY in 6125 and MUST-ignore in 9525, and both texts remain deployed.

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 confirmed Lemmy, Strapi, and Directus findings use the routing rows—CGNAT 100.64.0.0/10 and the bare-0 form—not the decoding-only rows. The tables label all 33 accordingly as “decodes to” rather than “routes to”.

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