Back to Blog
critical SEVERITY9 min read

How Server-Side Request Forgery happens in Node.js maintenance scripts and how to fix it

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 limi

O
By Orbis AppSec
Published September 1, 2026Reviewed September 1, 2026

Answer Summary

This is a Server-Side Request Forgery (SSRF) vulnerability (CWE-918) in a Node.js maintenance script (`maintenance/getImages.js`). The `getImage()` function called `axios.get(url)` with a URL read directly from the elements database, with no host or protocol validation. An attacker who could write to the database could redirect the server to fetch from internal endpoints like the AWS metadata service at `169.254.169.254`. The fix parses the URL and enforces that only `https://upload.wikimedia.org` is an allowed destination before any HTTP request is made.

Vulnerability at a Glance

cweCWE-918
fixParse the URL with `new URL()` and enforce an allowlist requiring `https:` protocol and `upload.wikimedia.org` hostname before making any request
riskAttacker can redirect server-side HTTP requests to internal network resources or cloud metadata endpoints, exposing credentials and infrastructure details
languageJavaScript (Node.js)
root cause`axios.get(url)` called with a database-sourced URL at line 48 of `getImages.js` without any host or protocol validation
vulnerabilityServer-Side Request Forgery (SSRF)

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:

  1. 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.

  2. The attacker inserts a malicious URL into the image.url field of an element record:
    http://169.254.169.254/latest/meta-data/iam/security-credentials/my-ec2-role

  3. The maintenance script runs (perhaps on a schedule, perhaps triggered manually). getImage() is called with this URL.

  4. 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-..." }

  5. The response is written to disk as an "image" file. The attacker can now retrieve it, or the credentials may appear in logs.

  6. 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.


Key Takeaways

  • Database fields storing URLs are SSRF vectors. The image.url field in the elements database fed directly into axios.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-in URL constructor gives you a structured, trustworthy breakdown of each component.

  • Maintenance scripts carry real production risk. getImages.js lives in a maintenance/ 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 if url is 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 url parameter in getImage( el, url ) is populated from the elements database — an external, potentially attacker-controlled data store.
  • Sink: axios.get( url, { responseType: 'arraybuffer' } ) at line 48 of maintenance/getImages.js — an outbound HTTP request made with the unvalidated URL.
  • Missing control: No hostname validation, protocol check, or allowlist was applied between reading url from the database and passing it to axios.get().
  • CWE: CWE-918 — Server-Side Request Forgery (SSRF).
  • Fix: Introduced new URL() parsing and an explicit allowlist check restricting requests to https://upload.wikimedia.org before 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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #145

Related Articles

medium

How gitlab.bandit.B501 happens in Python and how to fix it

The `proverbia-scraper.py` script disabled TLS certificate verification on its `requests.get()` call and silenced the resulting security warnings, exposing the scraper to man-in-the-middle attacks. The fix removes the `verify=False` flag and the warning suppression, restoring proper certificate validation while keeping the existing 30-second timeout intact.

high

How Server-Side Request Forgery (SSRF) happens in Go HTTP handlers and how to fix it

A Server-Side Request Forgery (SSRF) vulnerability was discovered in `internal/web/controller/server.go` where the `applySubTemplate` endpoint accepted arbitrary URLs from user input and passed them directly to `serverService.ApplySubTemplateFromGithub()` without any host validation. An attacker could exploit this to make the server issue HTTP requests to internal network resources, cloud metadata endpoints, or redirect-controlled destinations. The fix introduces a strict allowlist that restrict

critical

How SSRF via Vulnerable Dependency Versions Happens in Node.js and How to Fix It

A permissive semver range in `package.json` allowed npm to install axios versions vulnerable to SSRF (CVE-2024-39338). By bumping the minimum version from `^1.6.0` to `^1.7.4`, all downstream consumers of this SDK are now protected from server-side request forgery attacks. This critical fix required changing just one line in the dependency manifest.

critical

How Server-Side Request Forgery happens in Python FastAPI and how to fix it

A critical Server-Side Request Forgery (SSRF) vulnerability was discovered in app.py where the `/parse` and `/parse-video` endpoints accepted user-supplied URLs with only substring validation. The application checked if 'doubao.com' appeared anywhere in the URL string, allowing attackers to bypass this check and access internal services, cloud metadata endpoints, or scan the internal network. The fix implemented proper hostname parsing with an allowlist of legitimate domains.

high

How SSRF via inconsistent IP address parsing happens in Node.js dependencies and how to fix it

A high-severity flaw (CVE-2026-69192) in the widely-used `ip-address` npm package meant that IP strings could be parsed inconsistently compared to the OS resolver and Node's own networking stack — letting an attacker slip a private/loopback address past an allowlist that used `Address4`/`Address6` for validation. This PR pins and upgrades `ip-address` from `10.1.0` to `10.3.1` in both `package.json` (via `overrides`) and `package-lock.json`, eliminating the parser divergence across the whole dep

critical

ExternalHttpClient::request() Sent Basic Auth Over Plain HTTP

The `ExternalHttpClient::request()` helper accepted a `$basicAuth` string and passed it straight to the HTTP client's `auth` option without checking that the target URL used `https://`. Any external JSON data source configured with an `http://` endpoint therefore shipped a base64-encoded `Authorization: Basic` header in cleartext on every scheduled load. The fix rejects the request outright — before a client is even created — when the URL scheme is not HTTPS.