MarSeventh/CloudFlare-ImgBed PR #625 blocked SSRF in fetchRes
MarSeventh/CloudFlare-ImgBed exposed an authenticated server-side URL proxy through fetchRes. PR #625 adds URL validation, private address blocking and redirect revalidation.

MarSeventh/CloudFlare-ImgBed exposed an authenticated server-side fetch proxy that would retrieve any URL supplied in the request body. I fixed the issue in PR #625, which was applied on 15 July 2026. The affected code lived in functions/api/fetchRes.js, where the pre-fix security boundary was a single call:
const response = await fetch(targetUrl);That is the whole bug. The endpoint parsed a request, extracted url, passed it to server-side fetch() and streamed the response back to the caller. There was authentication through dualAuthCheck, but no URL parsing, no scheme allowlist, no internal address filtering and no redirect handling. For an authenticated user, the endpoint turned CloudFlare-ImgBed into a server-side request primitive.
The issue maps cleanly to CWE-918: Server-Side Request Forgery. The interesting part is not that SSRF exists in 2026. The interesting part is how little code it takes to create it when a convenience proxy crosses a network boundary.
The vulnerable code path
functions/api/fetchRes.js is designed to fetch a remote resource for the caller. The route first invokes dualAuthCheck, then reads url from the request body. If url is absent it returns 400. Before the patch, anything else went straight into fetch().
The relevant flow was:
- The caller sends a request body containing
url. fetchRes.jsassigns that value totargetUrl.- The endpoint calls
fetch(targetUrl)server-side. - The response body and headers are returned to the caller.
The auth gate matters, but it does not remove the vulnerability. A normal user token gives access to the application. It does not give that user the ability to make arbitrary outbound network requests from the server's network position. The vulnerable endpoint created that extra capability.
A request to a public URL was the intended behaviour:
curl -X POST https://<host>/api/fetchRes \
-H 'Cookie: <auth cookie>' \
-H 'Content-Type: application/json' \
-d '{"url":"https://example.com/"}'A request to an internal address was not:
curl -X POST https://<host>/api/fetchRes \
-H 'Cookie: <auth cookie>' \
-H 'Content-Type: application/json' \
-d '{"url":"http://169.254.169.254/latest/meta-data/"}'Where the runtime could reach that address, the server would issue the request and return the result. The same primitive could be used for loopback services, RFC1918 addresses, link-local targets and cloud metadata endpoints. The exact reachability depends on the deployment environment, but the application code did not enforce the boundary itself.
What made the bug exploitable
Four missing checks turned a helper endpoint into an SSRF sink.
First, the code did not parse the URL before use. Malformed or deliberately confusing input was delegated to the runtime rather than rejected at the application boundary.
Second, the code did not restrict protocols. A safe fetch proxy should normally accept only http: and https:. Without an explicit allowlist, the behaviour depends on the runtime's support for schemes such as file:, ftp:, data: or other protocol handlers. Some environments refuse these. Security should not depend on discovering that at exploit time.
Third, the code did not block internal destinations. Literal addresses such as 127.0.0.1, 10.0.0.5, 192.168.1.1 and 169.254.169.254 are not ordinary public resources. They represent the server's local and adjacent network view. Hostnames such as localhost, metadata.google.internal and names ending in .local or .internal carry the same warning.
Fourth, the code let the runtime follow redirects automatically. This is a common SSRF bypass. A naive filter might approve https://attacker.example/redir, then the attacker's server returns a 302 Location: http://127.0.0.1:8080/. If redirects are followed without revalidation, the initial check becomes decorative.
This is why SSRF fixes need to validate both the first URL and every later URL. OWASP's SSRF prevention guidance makes the same point at a design level: define allowed destinations, validate inputs and avoid trusting parser or DNS behaviour implicitly.
What PR #625 changes
PR #625 confines the change to functions/api/fetchRes.js. The patch adds an isPrivateHostname() helper near the top of the file, then validates targetUrl before any network request is made.
The first new boundary is parsing:
let parsed;
try {
parsed = new URL(targetUrl);
} catch (e) {
return new Response(JSON.stringify({ error: 'Invalid URL' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}Invalid input now stops at the endpoint with a 400 response. It no longer reaches fetch().
The second boundary is a protocol allowlist:
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return new Response(JSON.stringify({ error: 'Only http(s) URLs are allowed' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}This blocks file:, gopher:, data:, ftp:, blob: and any other scheme outside the endpoint's actual purpose.
The third boundary rejects embedded credentials:
if (parsed.username || parsed.password) {
return new Response(JSON.stringify({ error: 'Credentials in URL are not allowed' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}Credentials in URLs are often used to smuggle authentication into internal targets or to confuse URL handling. They are not needed for this proxy.
The fourth boundary is hostname classification. isPrivateHostname() blocks obvious local names, Google metadata hostnames, IPv4 private and reserved ranges, IPv6 loopback, IPv6 link-local, IPv6 unique-local and IPv4-mapped IPv6 private addresses. The IPv4 coverage includes 10.0.0.0/8, 127.0.0.0/8, 0.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10 and multicast or reserved space at 224.0.0.0/4 and above.
Finally, the patch changes redirect handling from implicit to manual:
let response = await fetch(parsed.toString(), { redirect: 'manual' });
let hops = 0;
while (response.status >= 300 && response.status < 400 && response.headers.get('location') && hops < 5) {
const next = new URL(response.headers.get('location'), parsed);
if ((next.protocol !== 'http:' && next.protocol !== 'https:') ||
next.username || next.password ||
isPrivateHostname(next.hostname)) {
return new Response(JSON.stringify({ error: 'Redirect to disallowed target' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
response = await fetch(next.toString(), { redirect: 'manual' });
hops++;
}Every redirect target is re-parsed and rechecked. Redirect chains are capped at five hops. A public URL can no longer act as a trampoline to 127.0.0.1 or 169.254.169.254.
The response status is also preserved in the returned Response. That is a correctness fix rather than the security control, but it avoids turning upstream errors into apparent 200 responses.
What testing covered
I tested the fix against both hostile and benign inputs. Internal IPv4 targets such as 127.0.0.1, 169.254.169.254, 10.0.0.5, 172.16.0.1, 172.31.255.255, 192.168.1.1 and 100.64.0.1 were classified as private. 172.32.0.1 was correctly not classified as private because it sits outside 172.16.0.0/12.
IPv6 cases covered ::1, ::ffff:127.0.0.1, fe80::1 and fd00::1. Hostname cases covered localhost, metadata.google.internal and .local names. URL parser checks confirmed that http://user:pass@127.0.0.1/ populates username and password, while file:///etc/passwd is identified as file: and rejected by the protocol allowlist.
A public URL such as https://example.com/ still reaches the fetch path. That matters because security patches which break the intended feature tend not to last.
The broader SSRF pattern
SSRF is an old class, but it keeps reappearing because developers add server-side fetchers for reasonable reasons: image proxies, link previews, import tools, webhooks, document loaders and agent browsing tools. The dangerous moment is when the code treats "the URL the user asked for" as equivalent to "a URL the server should be allowed to reach".
The high-impact version of the same class is visible in CVE history. NVD describes CVE-2021-26855 as a Microsoft Exchange Server SSRF vulnerability that allowed an unauthenticated attacker to send arbitrary HTTP requests and authenticate as the Exchange server. CloudFlare-ImgBed's issue is not that bug and it had an authenticated-user precondition, but the primitive is recognisable: attacker-controlled input causes a trusted server to make a request from a more privileged network position.
That pattern has become more relevant around AI and LLM systems, even when the immediate project is not an AI product. Modern AI tooling frequently adds URL fetchers, repository readers, file loaders and browser-like tools so models can gather context. In NVIDIA's RAG Blueprint MCP server, the problem was path input reaching file reads. In CodeGraphContext's Cypher issue, HTTP input reached a graph query engine. In AI tool supply-chain work, repository text and tool descriptions became operational inputs for agents.
The sink changes, but the structure is the same. A flexible helper accepts untrusted input. The helper runs with more authority than the caller. The code forgets to reduce that authority before acting.
What remains hard
PR #625 deliberately fixes the practical SSRF paths in application code. It does not claim to solve every network identity problem. DNS rebinding remains the awkward residual risk: a hostname can resolve to a public address during validation, then to a private address when the runtime opens the connection. Fully closing that requires resolving addresses under application control and pinning the actual connection to the validated address. That is not generally available through Workers-style fetch() APIs.
That limitation does not make the fix cosmetic. Blocking literal private addresses, internal hostnames, dangerous schemes, embedded credentials and redirect pivots removes the common exploit paths. More importantly, it puts the trust boundary where it belongs: before the server makes the request.
The uncomfortable lesson is that every URL proxy is a small browser running inside your infrastructure. If it has fewer navigation rules than a browser and more network access than a user, it should be treated as an attack surface before the first fetch() lands.
Newsletter
One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.