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.
Prevention & Best Practices
1. Always validate URLs before server-side fetches
Any time user input influences an outbound HTTP request, apply URL validation before the request is made. The check should happen at the point of consumption, not upstream — as demonstrated by placing isAllowedUrl() directly before fetchWithAuth().
2. Use allowlists, not just blocklists
The current fix uses a blocklist of known-dangerous ranges. For higher-security applications, consider inverting this: maintain an explicit allowlist of domains or IP ranges that the application is permitted to fetch from, and reject everything else. This is harder to bypass because unknown future infrastructure is blocked by default.
3. Resolve DNS before validating (DNS rebinding defense)
The current fix validates the hostname string, but a sophisticated attacker can use DNS rebinding: register a domain that initially resolves to a public IP (passing validation), then quickly switch the DNS record to 169.254.169.254 before the actual fetch. To prevent this, resolve the hostname to an IP address first, then validate the resolved IP. Libraries like ssrf-req-filter for Node.js handle this automatically.
4. Apply the fix to every fetch call site
Because server.js and worker.js each independently implemented the ?config= feature, both needed independent fixes. Audit your codebase for every location where user input flows into fetch(), http.request(), axios.get(), or similar — not just the most obvious one.
5. Use a dedicated SSRF-prevention library
For production applications, consider using a purpose-built SSRF prevention library rather than hand-rolled regex. Libraries maintain up-to-date blocklists and handle edge cases (IPv6, URL encoding, CNAME chains) more reliably.
OWASP and CWE references
- CWE-918: Server-Side Request Forgery
- OWASP Top 10 2021 — A10: Server-Side Request Forgery
- OWASP SSRF Prevention Cheat Sheet: Comprehensive guidance on blocklist strategies, allowlists, and network-layer controls
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.