How Server-Side Request Forgery happens in Node.js server.js and how to fix it
Introduction
The server.js file in this Node.js application handles remote configuration loading — a common and useful pattern where a client passes a URL pointing to a YAML config file. But a critical flaw in how that URL was consumed created a textbook Server-Side Request Forgery (SSRF) vulnerability: the configUrl parameter was read directly from the query string and handed to fetchWithAuth() with zero validation.
Here's the vulnerable code path in server.js, around line 52:
// BEFORE: No validation — any URL accepted
const configUrl = reqUrl.searchParams.get('config');
if (configUrl) {
const configRes = await fetchWithAuth(configUrl); // ← direct sink
...
}
And the same pattern existed independently in worker.js:
const configUrl = url.searchParams.get('config');
if (configUrl) {
const configRes = await fetchWithAuth(configUrl); // ← same sink, same risk
...
}
In both files, an attacker who can reach the /sub endpoint controls exactly where the server makes its next HTTP request. That's SSRF — and in cloud-hosted environments, it's a direct path to credential theft.
The Vulnerability Explained
What's actually happening
When a user hits /sub?config=https://example.com/config.yaml, the application fetches that YAML file and uses it for configuration. This is intentional behavior. The problem is that nothing stops the user from supplying any URL at all — including URLs that target infrastructure that's only reachable from the server's own network.
The fetchWithAuth() function is designed to make authenticated outbound requests. By feeding it an attacker-controlled URL, the attacker hijacks the server's network identity to probe or exfiltrate data from:
- Cloud metadata services —
http://169.254.169.254/latest/meta-data/iam/security-credentials/(AWS Instance Metadata Service) - Internal microservices —
http://10.0.0.5:8080/admin/dump - Loopback services —
http://127.0.0.1:6379(Redis, databases, etc.) - Non-HTTP schemes —
file:///etc/passwd(if the fetch implementation supports it)
The attack scenario
The PR documents the most dangerous exploit path concisely:
GET /sub?config=http://169.254.169.254/latest/meta-data/iam/security-credentials/
On an AWS EC2 instance or ECS container, this request returns a JSON blob containing temporary AWS credentials — AccessKeyId, SecretAccessKey, and Token — that grant full IAM permissions to whatever role the instance is running as. The attacker receives this response through the application's normal config-fetching response path, with no special access required beyond reaching the /sub endpoint.
The same attack works against GCP's metadata server (http://metadata.google.internal/), Azure's IMDS (http://169.254.169.254/metadata/instance), and any internal service that trusts requests originating from the application server's IP.
Why both files matter
server.js and worker.js implement the same ?config= feature independently. Fixing only one would leave a parallel attack surface open. This is a common pitfall in codebases where logic is duplicated across a main server process and a worker process — each copy of the vulnerable pattern needs its own fix.
The Fix
The fix introduces a dedicated isAllowedUrl() function in both server.js and worker.js that validates any user-supplied URL before it reaches fetchWithAuth().
The new validation function
// AFTER: Added to both server.js and worker.js
function isAllowedUrl(urlStr) {
const parsed = new URL(urlStr);
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
const host = parsed.hostname;
if (/^(localhost|127\.|0\.0\.0\.0|::1$)/.test(host)) return false;
if (/^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|169\.254\.)/.test(host)) return false;
return true;
}
Let's break down what each check does:
| Check | What it blocks |
|---|---|
['http:', 'https:'].includes(parsed.protocol) |
file://, ftp://, gopher://, data: URIs, and any other non-web scheme |
/^(localhost\|127\.\|0\.0\.0\.0\|::1$)/ |
IPv4 and IPv6 loopback addresses, blocking access to local services |
/^(10\.\|172\.(1[6-9]\|2\d\|3[01])\.\|192\.168\.\|169\.254\.)/ |
RFC-1918 private ranges AND the 169.254.x.x link-local range used by cloud metadata services |
Before and after comparison
server.js — before:
if (configUrl) {
const configRes = await fetchWithAuth(configUrl);
if (!configRes.ok) throw new Error(`Failed to fetch remote config: ${configRes.status}`);
server.js — after:
if (configUrl) {
if (!isAllowedUrl(configUrl)) throw new Error('Invalid or disallowed config URL');
const configRes = await fetchWithAuth(configUrl);
if (!configRes.ok) throw new Error(`Failed to fetch remote config: ${configRes.status}`);
worker.js — before:
if (configUrl) {
const configRes = await fetchWithAuth(configUrl);
worker.js — after:
if (configUrl) {
if (!isAllowedUrl(configUrl)) return new Response('Invalid or disallowed config URL', { status: 400 });
const configRes = await fetchWithAuth(configUrl);
Notice the slightly different error handling between the two files: server.js throws an error (which the surrounding try/catch handles), while worker.js returns a 400 Bad Request Response directly — matching each file's existing error-handling idiom. This is the right approach: the validation logic is identical, but the failure response respects each module's architecture.
Why new URL() is the right parsing tool
Using JavaScript's built-in URL constructor to parse the input before validation is critical. String-based checks like if (configUrl.startsWith('http')) are trivially bypassed with tricks like:
- http://169.254.169.254@evil.com (credentials in URL)
- URL-encoded characters
- Mixed-case protocols like HTTP:
The URL constructor normalizes all of these before the regex checks run, making the validation robust against encoding tricks.
Key Takeaways
- The
?config=parameter inserver.jsandworker.jswas a direct SSRF sink — any URL accepted byfetchWithAuth()was reachable by any client who could hit the/subendpoint. - The AWS metadata endpoint
169.254.169.254is the canonical SSRF target in cloud environments; blocking the entire169.254.x.xrange is a mandatory baseline for any application making server-side HTTP requests. - Duplicated vulnerable patterns require duplicated fixes —
worker.jshad the same flaw asserver.jsand needed its ownisAllowedUrl()call, not just a shared import. new URL()normalization before regex validation prevents encoding bypasses — always parse before you validate.- SSRF is not a theoretical risk in cloud environments — a single GET request to
/sub?config=http://169.254.169.254/...could have exfiltrated live AWS IAM credentials.
How Orbis AppSec Detected This
- Source: HTTP query parameter
config(url.searchParams.get('config')) in bothserver.jsandworker.js - Sink:
fetchWithAuth(configUrl)called with the unvalidated user-supplied URL, inserver.js:52and the corresponding line inworker.js - Missing control: No protocol validation, no IP range blocklist, no allowlist — the URL was used as-is with no sanitization between source and sink
- CWE: CWE-918 — Server-Side Request Forgery (SSRF)
- Fix: Added
isAllowedUrl()in both files to enforce HTTP/HTTPS-only protocols and block all RFC-1918 private ranges and the169.254.x.xlink-local range before any outbound request is dispatched
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
SSRF vulnerabilities in configuration-fetching features are particularly dangerous because they're easy to overlook — the feature is supposed to make outbound HTTP requests, so the behavior looks normal. What makes it a vulnerability is the absence of constraints on where those requests can go. In cloud-hosted applications, the gap between "fetch any URL" and "exfiltrate IAM credentials" is a single crafted GET request.
The fix here is a strong baseline: parse the URL with new URL(), enforce HTTP/HTTPS protocols, and block all private and link-local IP ranges. For applications where the set of valid config sources is known in advance, upgrading to an explicit allowlist would provide even stronger guarantees. Either way, the core principle holds: never let user-supplied URLs reach your HTTP client without validation.