How Server-Side Request Forgery Happens in Node.js Maintenance Scripts and How to Fix It
Summary
A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in maintenance/getImages.js, where the getImage() function passed database-sourced URLs directly to axios.get() without any validation. An attacker who could modify the elements database could redirect these requests to internal network resources — including AWS cloud metadata endpoints — potentially exposing IAM credentials and other sensitive infrastructure data. The fix introduces a strict URL allowlist that limits outbound requests exclusively to upload.wikimedia.org over HTTPS.
Introduction
The maintenance/getImages.js script has a straightforward job: look up image URLs from the elements database and download each image to the local filesystem. It's the kind of utility script that rarely gets a second look from a security perspective — it's not a public API endpoint, it doesn't accept user input directly, and it lives in a maintenance/ folder that implies infrequent, controlled use.
But that apparent innocuousness is exactly what makes it dangerous. The getImage() function, at line 48, does this:
const response = await axios.get( url, { responseType: 'arraybuffer' } );
The url variable comes from the elements database. If anyone — or anything — can write a malicious URL into that database, the server running this script will obediently fetch it. And "fetch it" means making an outbound HTTP request from your server's network context, with access to everything your server can reach: internal APIs, admin dashboards, and critically, cloud metadata services.
This is Server-Side Request Forgery (SSRF), and it's consistently ranked in the OWASP Top 10 (A10:2021) because it turns your own server into an unwitting proxy for attackers.
The Vulnerability Explained
What the Vulnerable Code Looks Like
Before the fix, the relevant section of getImage() looked like this:
async function getImage( el, url ) {
console.log( 'proceed image for "' + el + '" ...' );
const response = await axios.get( url, { responseType: 'arraybuffer' } );
const filetype = url.split( '.' ).reverse()[0].toString().toLowerCase();
const filepath = path + el + '.' + filetype;
// ... write file ...
}
There is no validation between receiving url from the database and passing it to axios.get(). The function trusts the database completely.
Why This Is a Problem
axios.get() will happily request any URL it's given — including:
http://169.254.169.254/latest/meta-data/iam/security-credentials/(AWS EC2 metadata service)http://localhost:8080/admin(local admin interfaces)http://10.0.0.1/(internal VPC resources)http://169.254.169.254/computeMetadata/v1/(GCP metadata service)file:///etc/passwd(local filesystem, depending on the HTTP client)
These addresses are unreachable from the public internet but are fully accessible from within the server's own network context. That's the core of SSRF: the attacker doesn't need network access to your internal systems — they just need to convince your server to make the request for them.
The Concrete Attack Scenario
Here's how this plays out against a cloud-hosted deployment:
-
An attacker gains write access to the elements database. This could be through a separate SQL injection vulnerability, a compromised admin account, or a misconfigured database permission.
-
The attacker inserts a malicious URL into the
image.urlfield of an element record:
http://169.254.169.254/latest/meta-data/iam/security-credentials/my-ec2-role -
The maintenance script runs (perhaps on a schedule, perhaps triggered manually).
getImage()is called with this URL. -
axios.get()fetches the metadata endpoint. The AWS metadata service responds with the EC2 instance's IAM role credentials:
json { "AccessKeyId": "ASIA...", "SecretAccessKey": "...", "Token": "...", "Expiration": "2024-..." } -
The response is written to disk as an "image" file. The attacker can now retrieve it, or the credentials may appear in logs.
-
With valid IAM credentials, the attacker can access S3 buckets, RDS databases, Lambda functions — whatever the EC2 role permits.
This is not a theoretical attack. SSRF against cloud metadata services is one of the most well-documented real-world attack chains, responsible for several high-profile data breaches.
The Fix
What Changed
The fix adds URL validation at the top of getImage(), immediately after the log statement and before any network call is made:
async function getImage( el, url ) {
console.log( 'proceed image for "' + el + '" ...' );
const allowedHost = 'upload.wikimedia.org';
const parsed = new URL( url );
if( parsed.protocol !== 'https:' || parsed.hostname !== allowedHost ) {
throw new Error( 'refused to fetch image for "' + el + '" from untrusted host "' + parsed.hostname + '"' );
}
const response = await axios.get( url, { responseType: 'arraybuffer' } );
// ...
}
Before vs. After
Before (vulnerable):
// Line 48 — no validation, url comes directly from database
const response = await axios.get( url, { responseType: 'arraybuffer' } );
After (secure):
const allowedHost = 'upload.wikimedia.org';
const parsed = new URL( url );
if( parsed.protocol !== 'https:' || parsed.hostname !== allowedHost ) {
throw new Error( 'refused to fetch image for "' + el + '" from untrusted host "' + parsed.hostname + '"' );
}
const response = await axios.get( url, { responseType: 'arraybuffer' } );
Why This Fix Works
1. new URL() provides reliable parsing. Rather than using string operations (like url.startsWith('https://'), which can be fooled by https://evil.com/https://upload.wikimedia.org), the fix uses the built-in URL constructor to parse the URL into its components. parsed.hostname gives you the actual host, immune to path-based tricks.
2. The allowlist is strict and explicit. Only upload.wikimedia.org is permitted. Not *.wikimedia.org, not wikimedia.org — exactly upload.wikimedia.org. This means subdomain takeover attacks on adjacent Wikimedia subdomains can't be leveraged to bypass the check.
3. HTTPS is enforced. The protocol check parsed.protocol !== 'https:' ensures that even if someone injects a URL pointing to the allowed host over HTTP, it will be rejected. This prevents downgrade attacks and ensures traffic is encrypted.
4. The error message is informative but safe. The thrown error includes the rejected hostname (parsed.hostname), which helps with debugging without leaking sensitive information. The error causes the script to skip the malicious entry rather than silently failing or writing unexpected data to disk.
5. The fix is non-breaking for valid inputs. All legitimate image URLs in this codebase point to Wikimedia Commons (hosted at upload.wikimedia.org). The allowlist is scoped to exactly the valid use case, so no legitimate functionality is affected.
Prevention & Best Practices
1. Always Validate URLs Before Making Outbound Requests
Whenever your code fetches a URL that comes from an external source — a database, an API response, a user input, a configuration file — treat it as untrusted. Parse it and validate it before use.
// Safe pattern: parse first, validate second, fetch third
const parsed = new URL( url );
if ( !ALLOWED_HOSTS.has( parsed.hostname ) || parsed.protocol !== 'https:' ) {
throw new Error( `Blocked request to untrusted host: ${parsed.hostname}` );
}
const response = await axios.get( url );
2. Prefer Allowlists Over Blocklists
Blocklists (e.g., "reject requests to 169.254.169.254") are fragile. Attackers can bypass them with:
- Alternative IP representations: 0xa9fea9fe, 169.254.169.254 in octal
- DNS rebinding: a domain that resolves to a public IP during validation but a private IP during the actual request
- HTTP redirects: your server fetches a "safe" URL that redirects to a private one
An allowlist that says "only upload.wikimedia.org over HTTPS" eliminates all of these bypass techniques.
3. Apply SSRF Protections at the Network Level Too
Defense in depth: even if application-level validation is bypassed, network-level controls can limit the damage:
- Use egress firewall rules to block outbound requests to RFC 1918 private IP ranges
- On AWS, use IMDSv2 (requiring a PUT request before GET) to make metadata service SSRF harder
- Run maintenance scripts in isolated network environments without access to production infrastructure
4. Audit Database-Sourced URLs Regularly
Any field in your database that stores a URL and is later fetched by server-side code is a potential SSRF vector. Audit these patterns in code review and in static analysis.
5. Use Static Analysis to Catch SSRF
Tools like Semgrep can be configured to flag patterns where data flows from a database query to an HTTP client call without an intervening validation step. This is exactly the pattern Orbis AppSec detected in this case.
Relevant standards:
- OWASP SSRF Prevention Cheat Sheet
- CWE-918: Server-Side Request Forgery
- OWASP Top 10 A10:2021 – Server-Side Request Forgery
Key Takeaways
-
Database fields storing URLs are SSRF vectors. The
image.urlfield in the elements database fed directly intoaxios.get()— any field like this needs validation before use in an HTTP client call. -
new URL()is the right tool for URL parsing in Node.js. String matching on URLs is unreliable and bypassable; the built-inURLconstructor gives you a structured, trustworthy breakdown of each component. -
Maintenance scripts carry real production risk.
getImages.jslives in amaintenance/folder, but it runs with production database access and production network permissions. "Maintenance" doesn't mean "low security." -
A one-line
axios.get(url)can expose your entire cloud infrastructure ifurlis attacker-controlled and the script runs on a cloud VM with an IAM role attached. -
Allowlists beat blocklists for SSRF. The fix doesn't try to enumerate bad hosts — it specifies exactly one good host (
upload.wikimedia.org) and rejects everything else.
How Orbis AppSec Detected This
- Source: The
urlparameter ingetImage( el, url )is populated from the elements database — an external, potentially attacker-controlled data store. - Sink:
axios.get( url, { responseType: 'arraybuffer' } )at line 48 ofmaintenance/getImages.js— an outbound HTTP request made with the unvalidated URL. - Missing control: No hostname validation, protocol check, or allowlist was applied between reading
urlfrom the database and passing it toaxios.get(). - CWE: CWE-918 — Server-Side Request Forgery (SSRF).
- Fix: Introduced
new URL()parsing and an explicit allowlist check restricting requests tohttps://upload.wikimedia.orgbefore any HTTP call is made.
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 getImages.js SSRF vulnerability is a textbook example of how a seemingly innocuous utility script can become a critical security risk. The script's job — downloading images from URLs stored in a database — is completely legitimate. But the absence of a single validation step transformed it into a potential gateway to cloud credential theft and internal network reconnaissance.
The fix is elegant in its simplicity: parse the URL, check the hostname and protocol against an explicit allowlist, and throw an error if anything doesn't match. Six lines of code close a critical attack vector. This is the kind of defense that's easy to add during development and very hard to add after a breach.
For developers writing similar scripts — anything that reads a URL from a database or external source and fetches it — the lesson is clear: validate before you fetch, use allowlists over blocklists, and treat every database-sourced value as potentially attacker-controlled.