The Vulnerability at a Glance
| Field | Detail |
|---|---|
| Vulnerability | Server-Side Request Forgery (SSRF) |
| CWE | CWE-918 |
| Language | JavaScript |
| Risk | Server fetches attacker-controlled URLs, exposing internal infrastructure |
| Root Cause | location passed directly to fetch() with no URL validation |
| Fix | Protocol check via new URL() before any fetch() call |
Quick Answer
What is this vulnerability and how was it fixed?
This is a Server-Side Request Forgery (SSRF) vulnerability (CWE-918) in JavaScript. ThecreateProxyResponsefunction infunctions/stream/createProxyResponse.jsaccepted alocationparameter and passed it directly tofetch()without parsing or validating the URL. An attacker could supply a URL likefile:///etc/passwdorhttp://169.254.169.254/latest/meta-data/to make the server fetch sensitive internal resources. The fix adds anew URL()parse step and rejects any protocol that is nothttp:orhttps:, blocking all non-web schemes before the fetch is ever executed.
Introduction
The functions/stream/createProxyResponse.js file acts as a streaming proxy — it accepts a location parameter and a request object, then uses the server-side fetch() API to retrieve that location on behalf of a client. It is exactly the kind of utility that feels simple and low-risk, right up until an attacker realizes the server will fetch anything you tell it to.
The vulnerability lives in a single line:
const response = await fetch(location, {
method: request.method,
headers: request.headers,
No parsing. No protocol check. No hostname validation. Whatever string arrives in location goes straight into fetch(). Combined with a wildcard Access-Control-Allow-Origin: * CORS header that permits any website to trigger cross-origin requests, this function became a fully open proxy that attackers could point at internal infrastructure.
The Vulnerability Explained
What the vulnerable code looked like
Before the fix, the entire createProxyResponse function began like this:
// VULNERABLE — before the fix
export async function createProxyResponnse (location, request) {
const response = await fetch(location, {
method: request.method,
headers: request.headers,
});
// ...
}
The location variable is entirely attacker-controlled. There is no call to new URL(), no regular expression check, no allowlist lookup — nothing stands between the raw string and the server-side fetch() call.
Why fetch() makes this dangerous
In a browser, fetch() is constrained by the Same-Origin Policy and CORS. On a server (Node.js, Cloudflare Workers, Deno, etc.), fetch() has no such restrictions. It will happily:
- Follow
http://andhttps://URLs to any host, includinglocalhostand RFC-1918 private ranges - Resolve
file://URIs and read local files (in some runtimes) - Send requests to
gopher://,dict://, and other exotic schemes that can be abused to interact with non-HTTP services - Reach cloud provider metadata endpoints that are only accessible from within the cloud environment
A concrete attack scenario
Consider an attacker who discovers this proxy endpoint. They craft a request with:
location = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
The server calls:
await fetch("http://169.254.169.254/latest/meta-data/iam/security-credentials/", ...)
On AWS, this is the Instance Metadata Service (IMDS). The response contains temporary IAM credentials — AccessKeyId, SecretAccessKey, and SessionToken — that grant the attacker the same AWS permissions as the application itself. The wildcard CORS header means a malicious webpage can trigger this fetch from any visitor's browser session, exfiltrating the credentials to an attacker-controlled server.
The same technique works against:
| Target | URL |
|---|---|
| GCP metadata | http://metadata.google.internal/computeMetadata/v1/ |
| Azure IMDS | http://169.254.169.254/metadata/instance |
| Local Redis | http://127.0.0.1:6379/ |
| Internal admin panel | http://10.0.0.1/admin |
| Local file read | file:///etc/passwd |
Why the wildcard CORS header amplifies the risk
The Access-Control-Allow-Origin: * header means any website — a phishing page, a malicious ad, a compromised third-party script — can make a cross-origin request to this proxy endpoint from inside a victim's browser. The browser will happily attach any cookies or credentials the victim holds, and the proxy will forward the response back to the attacker's page. This turns a server-side vulnerability into a client-assisted attack vector.
The Fix
What changed
The fix adds four lines at the top of createProxyResponnse, before fetch() is ever called:
// FIXED — after the patch
export async function createProxyResponnse (location, request) {
const parsedUrl = new URL(location);
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
return new Response("Invalid URL", { status: 400 });
}
const response = await fetch(location, {
method: request.method,
headers: request.headers,
});
// ...
}
Before vs. after
export async function createProxyResponnse (location, request) {
+ const parsedUrl = new URL(location);
+ if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") {
+ return new Response("Invalid URL", { status: 400 });
+ }
const response = await fetch(location, {
method: request.method,
headers: request.headers,
Why each line matters
const parsedUrl = new URL(location);
The URL constructor performs strict RFC-3986 parsing. If location is not a valid URL at all, this throws a TypeError, which should be caught by the surrounding error handler. More importantly, it gives us a structured parsedUrl object with a reliable .protocol property — no regex needed.
if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:")
This is the core gate. It explicitly allows only the two standard web protocols and rejects everything else: file:, gopher:, dict:, ftp:, data:, and any other scheme an attacker might try. Note that URL.protocol always includes the trailing colon (e.g., "https:"), which is why the comparison strings include it.
return new Response("Invalid URL", { status: 400 });
Rather than throwing an unhandled error, the function returns a clean 400 Bad Request response. This is important for proxy functions that may be wrapped in streaming logic — a thrown exception could cause the response stream to hang or leak partial data.
What the fix does NOT yet address
The protocol check is a necessary first step, but a robust SSRF defense also needs:
- Hostname allowlisting: Even with
http:/https:enforced, the server can still reachhttp://127.0.0.1,http://10.0.0.1, orhttp://169.254.169.254. A hostname or IP-range blocklist (or better, an explicit allowlist) would close this remaining gap. - DNS rebinding protection: Resolve the hostname once and validate the resolved IP before connecting.
- Redirect following controls:
fetch()follows redirects by default. An attacker can use an allowed external URL that redirects to an internal address.
Prevention & Best Practices
1. Always parse before you fetch
Never pass a raw string to fetch(), axios.get(), http.request(), or any HTTP client. Parse it first:
const parsedUrl = new URL(userSuppliedLocation);
// Now validate parsedUrl.protocol, parsedUrl.hostname, etc.
2. Use an explicit allowlist, not just a blocklist
Blocklists are fragile. An allowlist of permitted hostnames or domains is far harder to bypass:
const ALLOWED_HOSTS = new Set(["api.example.com", "cdn.example.com"]);
const parsedUrl = new URL(location);
if (!ALLOWED_HOSTS.has(parsedUrl.hostname)) {
return new Response("Host not allowed", { status: 400 });
}
3. Block private IP ranges after DNS resolution
If an allowlist is not feasible, resolve the hostname and reject RFC-1918 and link-local addresses:
import dns from "node:dns/promises";
const { address } = await dns.lookup(parsedUrl.hostname);
if (isPrivateIP(address)) {
return new Response("Internal addresses not allowed", { status: 400 });
}
4. Restrict CORS headers
The wildcard Access-Control-Allow-Origin: * on a proxy endpoint is dangerous. Restrict it to known origins:
const ALLOWED_ORIGINS = ["https://app.example.com"];
const origin = request.headers.get("Origin");
if (ALLOWED_ORIGINS.includes(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
}
5. Relevant standards and references
- OWASP SSRF Prevention Cheat Sheet: The definitive guide to layered SSRF defenses
- CWE-918: Server-Side Request Forgery — the formal classification for this vulnerability class
- OWASP Top 10 2021 — A10: Server-Side Request Forgery: SSRF earned its own Top 10 category in 2021, reflecting how common and impactful it has become
Key Takeaways
createProxyResponsefunctions are high-value SSRF targets: Any function whose job is to fetch a caller-supplied URL on behalf of a client deserves the strictest URL validation in your codebase.- Protocol validation with
new URL()is the minimum viable fix: CheckingparsedUrl.protocolagainst an allowlist of"http:"and"https:"blocksfile://,gopher://, and other dangerous schemes without breaking legitimate use cases. - A wildcard CORS header on a proxy endpoint is a force multiplier: It allows any website to trigger the proxy from a victim's browser, turning a server-side bug into a cross-origin exfiltration primitive.
- Protocol filtering alone does not stop SSRF to internal HTTP services: After enforcing the protocol, validate the resolved IP address against private ranges to prevent requests to
169.254.169.254,10.x.x.x, and127.0.0.1. - Cloud metadata endpoints are the most critical SSRF target: A successful fetch to
http://169.254.169.254/latest/meta-data/iam/security-credentials/can yield live AWS IAM credentials, giving an attacker full control of cloud resources.
How Orbis AppSec Detected This
- Source: The
locationparameter passed tocreateProxyResponnse()infunctions/stream/createProxyResponse.js— an externally supplied, user-controlled string with no prior sanitization. - Sink:
fetch(location, { method: request.method, headers: request.headers })at the top of the function body — a direct, unconditional HTTP request using the tainted value. - Missing control: No URL parsing, no protocol allowlist, no hostname validation, and no IP-range blocklist between the source and the sink.
- CWE: CWE-918 — Server-Side Request Forgery (SSRF).
- Fix: Added
new URL(location)parsing and a protocol guard that returns400 Bad Requestfor any scheme other thanhttp:orhttps:beforefetch()is invoked.
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 createProxyResponse vulnerability is a textbook example of how a single missing validation step can transform a useful utility into a critical security liability. The function's entire purpose — fetching a remote resource on behalf of a caller — is exactly what makes SSRF so dangerous here: the attacker is not subverting the function, they are using it exactly as designed, just pointed at the wrong target.
The fix is elegant in its simplicity: two lines that parse the URL and check the protocol. But the deeper lesson is architectural. Any time your server accepts a URL from an external caller and makes an outbound HTTP request with it, you need to treat that URL as untrusted input and apply the same rigor you would to SQL parameters or shell arguments. Parse it, validate it, and constrain it to only the destinations your application actually needs to reach.
SSRF earned its place in the OWASP Top 10 in 2021 because it is both common and catastrophic. Proxy functions, webhook handlers, URL preview generators, and import-from-URL features are all potential SSRF entry points. Audit yours.