Introduction
A Cloudflare Worker acting as a reverse proxy read an operator-configured upstream_url from its environment and forwarded incoming requests to it. The problem: the handler built the destination URL by concatenating that trusted value with two attacker-controlled pieces of the incoming request — pathname and search — using plain string addition. Whatever a client put in the path or query string of the request to the Worker ended up glued directly onto the end of the upstream origin string before that string was ever parsed as a URL.
This matters because JavaScript's URL constructor is only as safe as what you feed it. Concatenating raw strings and then parsing the result gives an attacker a chance to smuggle characters — extra slashes, @, encoded host separators, or a full second URL — into positions that change how the final string resolves. For a reverse proxy, that's the difference between "forward this request to the configured backend" and "forward this request wherever the client wants."
Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | not applicable (first-party code) |
| Ecosystem | Cloudflare Workers (JavaScript) |
| CVE / GHSA | not assigned |
| CWE | unknown |
There's no package version to check here — this is first-party Worker code. If you run a similarly-shaped reverse proxy that builds destination URLs from an environment variable plus request path data, the relevant question isn't "which version am I on" but "does my code concatenate before parsing."
The Vulnerability Explained
The vulnerable handler took the configured upstream origin and appended the request's path and query directly, only turning the result into a Request object afterward:
return fetch(new Request(env['upstream_url'] + pathname + search, {
body: request.body,
headers: request.headers,
method: request.method,
At the point of concatenation, env['upstream_url'] + pathname + search is still a plain string. The Request constructor will parse it as a URL, but by then the damage is already done: pathname and search came straight from the incoming request (new URL(request.url) earlier in the handler) and were never validated against the configured origin. A client who controls the path or query on the Worker's own public endpoint effectively controls a suffix of the string that gets parsed as the destination URL.
An attacker sending a request to the proxy could craft a path that, once appended to upstream_url, resolves to a different authority than intended — pointing the fetch at an external attacker-controlled domain, or, depending on how the Worker's network egress is configured, at internal infrastructure the Worker can reach but the public internet cannot. Since the Worker forwards request.body, request.headers, and request.method verbatim to whatever URL results, this is a classic SSRF pivot: the Worker becomes a proxy for making arbitrary outbound HTTP requests on the attacker's behalf, potentially exposing internal services, cloud metadata endpoints, or leaking whatever credentials/headers ride along with the forwarded request.
The Fix
The fix stops treating the destination as a string to be assembled and starts treating it as a URL object to be modified:
let upstream;
try {
upstream = new URL(env['upstream_url']);
} catch (e) {
return new Response('环境变量upstream_url格式错误,格式:https://xxxx.com');
}
upstream.pathname = pathname;
upstream.search = search;
return fetch(new Request(upstream, {
Two things changed, and both matter:
upstream_urlis parsed first, in isolation.new URL(env['upstream_url'])establishes the scheme, host, and port from the trusted environment value alone, with no request-controlled input mixed in yet. If the operator misconfiguredupstream_url, thecatchblock now returns a clear error instead of silently building a broken or attacker-influenced URL.pathnameandsearchare assigned as properties, not concatenated as text. TheURLobject's setters normalize and scope these values to the path/query portion of the already-fixed origin. There's no way for a craftedpathnameto inject a new scheme or authority — the origin was locked in during step one and the setters can't overwrite it.
The net effect: the final URL passed to fetch always has the host from upstream_url, and only the path/query can vary with the incoming request — which is exactly the trust boundary a reverse proxy is supposed to enforce.
Key Takeaways
- Never build a URL by concatenating a trusted origin with untrusted path/query data as strings — parse the trusted part first with
new URL(), then assignpathname/searchon the resulting object. fetch(new Request(someString, ...))will parsesomeStringas a URL for you, but that parsing happens after your string concatenation, not before — it offers no protection against injected authority components.- A reverse proxy that forwards
request.body,request.headers, andrequest.methodto a dynamically constructed destination is a high-value SSRF target; validate the destination's origin before the fetch, not after. - Fail closed on configuration errors: wrapping the origin parse in a
try/catchand returning an explicit error response is cheap insurance against silently proxying to an unintended host.
How Orbis AppSec Detected This
- Source: the incoming request's
pathnameandsearch, derived fromrequest.urlon the Worker's public endpoint - Sink:
fetch(new Request(url, ...)), whereurlwas built via string concatenation ofenv['upstream_url']with the request path and query - Missing control: no validation that the request-controlled path/query could not alter the authority (scheme/host/port) of the destination URL
- CWE: unknown (not classified in the source finding)
- Fix: parse
upstream_urlwithnew URL()first, then assignpathnameandsearchas properties on that object before fetching
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
The bug here wasn't exotic — it was a single + operator standing between a trusted configuration value and untrusted request data. String concatenation followed by URL parsing is a pattern that looks safe at a glance because the end result still gets fed into new URL() or new Request() eventually, but by the time that happens, the attacker's input has already merged with the trusted origin. Rebuilding the destination as a URL object first, and only ever mutating its pathname and search, keeps the trust boundary where it belongs: the host comes from configuration, the path and query come from the request, and neither can leak into the other's territory.