Back to Blog
critical SEVERITY9 min read

How Server-Side Request Forgery (SSRF) Happens in Node.js fetch Tools and How to Fix It

A critical Server-Side Request Forgery (SSRF) vulnerability in `plugins/tools/fetch.js` allowed attackers to access internal resources and cloud metadata endpoints by passing arbitrary URLs to the fetch command. The fix adds hostname resolution and private IP range validation before executing any HTTP requests, preventing attackers from targeting internal infrastructure.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 28, 2026•Reviewed August 28, 2026

Answer Summary

This is a Server-Side Request Forgery (SSRF) vulnerability (CWE-918) in Node.js that occurs when user-supplied URLs are passed to fetch() without validation. An attacker could access internal resources like AWS metadata endpoints (169.254.169.254) or private IP ranges. The fix adds DNS resolution of the hostname and validates that the resolved IP address is not in private ranges (10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, and IPv6 equivalents) before allowing the request.

Vulnerability at a Glance

cweCWE-918
fixResolve hostname to IP address and validate against private IP ranges before making HTTP request
riskAttackers can access internal resources, cloud metadata endpoints, and exfiltrate sensitive data through the server
languageJavaScript (Node.js)
root causeUser-supplied URL parameter passed directly to fetch() without validation of target IP address ranges
vulnerabilityServer-Side Request Forgery (SSRF)

How Server-Side Request Forgery (SSRF) Happens in Node.js fetch Tools and How to Fix It

Introduction

In the plugins/tools/fetch.js file, a critical vulnerability allowed attackers to abuse the fetch command to access internal resources and sensitive cloud metadata. The vulnerable code at line 17 accepted a user-supplied URL via the text parameter and passed it directly to fetch(text) without any validation of the target IP address. This meant an attacker could craft a WhatsApp message with .fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/ to retrieve AWS instance credentials from the metadata service—a classic Server-Side Request Forgery (SSRF) attack.

The issue wasn't that the code validated URL format (it did check for https:// or http:// at line 16), but rather that it failed to validate where those URLs actually pointed. An attacker could use a perfectly valid URL format pointing to a private IP address, and the server would dutifully fetch it.

The Vulnerability Explained

The Vulnerable Code Pattern

module.exports = {
   help: ['fetch'],
   use: '<URL>',
   async run({ usedPrefix, command, text }) {
      if (!/^https?:\/\//.test(text)) throw Func.example(usedPrefix, command, 'https://google.com')

      const res = await fetch(text)  // ← VULNERABLE: No IP validation!
      const length = Number(res.headers.get('content-length') || 0)
      if (length > 100 * 1024 * 1024) throw `Content is too large: ${length} bytes`

The problem is subtle but critical: the code validates the URL format (lines 15-16) but never validates the URL destination. The regex check if (!/^https?:\/\//.test(text)) only ensures the URL starts with http:// or https://—it says nothing about where that URL points.

Why This Is Exploitable

An attacker can craft URLs that pass the format check but point to internal resources:

  • https://169.254.169.254/latest/meta-data/iam/security-credentials/ — AWS metadata endpoint
  • https://192.168.1.1/admin/ — Internal router admin panel
  • https://10.0.0.5:5432/ — Internal database service
  • https://127.0.0.1:8080/internal-api/ — Localhost-bound services
  • https://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity — Google Cloud metadata

All of these URLs pass the regex check but represent internal resources that should never be accessible via a user-controlled fetch command.

Exploitation Scenario

An attacker sends a WhatsApp message to a bot running this code:

.fetch http://169.254.169.254/latest/meta-data/iam/security-credentials/

The server:
1. Checks the URL format ✓ (passes regex)
2. Calls fetch(text) ✗ (no IP validation)
3. Makes an HTTP request to the AWS metadata endpoint
4. Returns AWS credentials to the attacker

The attacker now has valid AWS credentials and can access all resources the instance has permission to access.

Real-World Impact

This vulnerability is particularly dangerous because:
- Cloud environments: Most modern applications run on AWS, GCP, or Azure where metadata endpoints expose sensitive credentials
- Internal services: Private databases, admin panels, and internal APIs become directly accessible
- Data exfiltration: Attackers can read files from internal file servers
- Lateral movement: Compromised credentials enable further attacks within the infrastructure

The Fix

The fix adds three critical security controls:

  1. Hostname resolution — Convert the URL's hostname to an IP address
  2. Private IP detection — Check if the resolved IP is in private/reserved ranges
  3. Explicit blocking — Reject requests to private IPs before calling fetch()

Before and After Comparison

Before (Vulnerable):

const util = require('util')

module.exports = {
   help: ['fetch'],
   use: '<URL>',
   async run({ usedPrefix, command, text }) {
      if (!/^https?:\/\//.test(text)) throw Func.example(usedPrefix, command, 'https://google.com')

      const res = await fetch(text)  // ← Directly fetches without IP validation
      const length = Number(res.headers.get('content-length') || 0)
      if (length > 100 * 1024 * 1024) throw `Content is too large: ${length} bytes`

After (Fixed):

const util = require('util')
const dns = require('dns').promises        // ← Added: DNS resolution
const net = require('net')                 // ← Added: IP validation utilities

function isPrivateIP(ip) {                 // ← Added: Private IP detection
   if (net.isIPv4(ip)) {
      const [a, b] = ip.split('.').map(Number)
      return a === 10 || a === 127 || a === 0 ||
         (a === 169 && b === 254) ||       // ← AWS metadata range
         (a === 172 && b >= 16 && b <= 31) ||  // ← Private range
         (a === 192 && b === 168)          // ← Private range
   }
   return ip === '::1' || /^fe80:/i.test(ip) || /^f[cd]/i.test(ip)  // ← IPv6 checks
}

module.exports = {
   help: ['fetch'],
   use: '<URL>',
   async run({ usedPrefix, command, text }) {
      if (!/^https?:\/\//.test(text)) throw Func.example(usedPrefix, command, 'https://google.com')

      const { hostname } = new URL(text)           // ← Extract hostname from URL
      const { address } = await dns.lookup(hostname)  // ← Resolve to IP address
      if (isPrivateIP(address)) throw 'This URL is not allowed.'  // ← Block private IPs

      const res = await fetch(text)        // ← Now safe to fetch
      const length = Number(res.headers.get('content-length') || 0)
      if (length > 100 * 1024 * 1024) throw `Content is too large: ${length} bytes`

How Each Change Works

  1. const dns = require('dns').promises — Imports Node.js's DNS resolution module. We use the promise-based API for cleaner async/await syntax.

  2. const net = require('net') — Imports Node.js's network utilities, specifically net.isIPv4() for checking if a string is a valid IPv4 address.

  3. isPrivateIP(ip) function — This is the security control. It checks if an IP address falls into reserved/private ranges:
    - 10.0.0.0/8 — Private network range (detected by a === 10)
    - 127.0.0.0/8 — Loopback/localhost (detected by a === 127)
    - 0.0.0.0/8 — "This" network (detected by a === 0)
    - 169.254.0.0/16 — Link-local/metadata endpoints (detected by a === 169 && b === 254)
    - 172.16.0.0/12 — Private range (detected by a === 172 && b >= 16 && b <= 31)
    - 192.168.0.0/16 — Private range (detected by a === 192 && b === 168)
    - IPv6 equivalents — Loopback (::1), link-local (fe80::/10), and unique local (fc00::/7)

  4. const { hostname } = new URL(text) — Safely extracts the hostname from the user-supplied URL using Node.js's built-in URL parser. This is safer than regex parsing.

  5. const { address } = await dns.lookup(hostname) — Performs DNS resolution to convert the hostname to an actual IP address. This is crucial because attackers might use domain names that resolve to private IPs.

  6. if (isPrivateIP(address)) throw 'This URL is not allowed.' — Checks the resolved IP against the private ranges and blocks the request if it matches.

Why DNS Resolution Is Critical

Without DNS resolution, an attacker could bypass IP-based checks using domain names:
- https://internal-db.local/ — Resolves to 10.0.0.5
- https://metadata.local/ — Resolves to 169.254.169.254

The fix handles this by resolving the hostname first, then checking the actual IP.

Prevention & Best Practices

For Your Own Code

  1. Always validate URL destinations, not just formats — Checking URL format is necessary but insufficient. Always resolve hostnames and validate the target IP.

  2. Implement an IP allowlist, not just a blocklist — If your application needs to make external requests, consider whitelisting specific domains or IPs rather than blocking private ranges.

  3. Use established libraries — Consider using libraries like node-fetch-retry or axios with interceptors that can enforce SSRF protections consistently.

  4. Separate concerns — If possible, have a dedicated service for making external HTTP requests with strict controls, rather than allowing arbitrary fetch calls throughout your codebase.

  5. Log and monitor — Log all blocked SSRF attempts. A pattern of SSRF attacks might indicate reconnaissance activity.

Security Standards and References

  • CWE-918: Server-Side Request Forgery (SSRF) — The official vulnerability classification
  • OWASP Top 10 2021 - A06: Vulnerable and Outdated Components — While not directly listed, SSRF is a critical server-side vulnerability
  • OWASP SSRF Prevention Cheat Sheet — Comprehensive guidance on preventing SSRF

Detection Tools

  • Static analysis — Tools like Semgrep can detect patterns where user input flows to fetch/http calls
  • Runtime monitoring — Monitor outbound connections for attempts to reach private IP ranges
  • Network segmentation — Restrict outbound access from application servers to only necessary external services

Key Takeaways

  • Never trust URL format alone — The original code validated that URLs started with http:// or https://, but this provided zero protection against SSRF because attackers could craft valid URLs pointing to internal IPs.

  • Always resolve hostnames before validating — Domain names can resolve to private IPs, so DNS resolution must happen before the IP range check. The fix uses dns.lookup() to convert hostnames to actual IP addresses.

  • Private IP ranges are well-defined — The isPrivateIP() function checks against standardized ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, etc.). These should be hardcoded in any SSRF protection logic.

  • Cloud metadata endpoints are high-value targets — The AWS metadata endpoint at 169.254.169.254 is specifically dangerous because it exposes credentials. The fix explicitly blocks the 169.254.0.0/16 range.

  • Async DNS resolution adds complexity but is necessary — The fix uses await dns.lookup(), making the function async. This is a required change to properly validate hostnames before making requests.

How Orbis AppSec Detected This

Source: The text parameter from the user-supplied WhatsApp command (e.g., .fetch http://169.254.169.254/...)

Sink: The fetch(text) call at line 17 of plugins/tools/fetch.js

Missing control: The code lacked any validation that the URL's resolved IP address was not in a private/reserved range. While the regex check validated URL format, it provided no protection against SSRF.

CWE: CWE-918 — Server-Side Request Forgery (SSRF)

Fix: Added DNS hostname resolution and IP range validation using the isPrivateIP() function to block requests to private, loopback, link-local, and cloud metadata IP ranges before calling fetch().

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

Server-Side Request Forgery vulnerabilities are particularly dangerous in modern cloud environments where metadata endpoints expose sensitive credentials. The original code in fetch.js demonstrated a common mistake: validating URL format while ignoring URL destination. By adding DNS resolution and private IP range validation, the fix ensures that the fetch command can only reach external, public-facing services.

The lesson here extends beyond this specific vulnerability: security controls must validate the actual intent and impact of user input, not just its format. A URL that looks correct might point to a dangerous location. Always resolve, validate, and verify before executing potentially dangerous operations.

For teams maintaining similar code that makes HTTP requests based on user input, implement these controls immediately. SSRF vulnerabilities are trivial to exploit and can lead to complete infrastructure compromise in cloud environments.

References

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #29

Related Articles

high

axios 1.18.1 CVE-2026-101898: HTTP/2 Ignores proxy and lookup

A dependency upgrade moved `axios` from 1.18.1 to 1.20.0 to pick up the fix for CVE-2026-101898, in which proxy and DNS resolution settings supplied through axios request configuration were not applied on the HTTP/2 request path. Applications that rely on `config.proxy`, `HTTP_PROXY`/`NO_PROXY` environment variables, or a custom `lookup` function as an SSRF or egress control could have those controls silently skipped for HTTP/2 requests.

critical

Cloudflare Worker Reverse Proxy SSRF via String Concatenation

A Cloudflare Worker reverse proxy built its upstream request URL by string-concatenating an environment variable with the incoming request's pathname and search string, letting a crafted path or query redirect requests to attacker-chosen hosts. The fix replaces concatenation with the `URL` object's `pathname` and `search` setters, which normalize input against the configured origin instead of blindly appending it.

high

fast-uri 3.1.2 IDN Host Bypass: CVE-2026-13676 Upgrade

A dependency tree in this repository resolved `fast-uri` 3.1.2, a version affected by CVE-2026-13676, in which improper Unicode hostname canonicalization lets a crafted URI parse to one host while the HTTP client connects to another. Because `fast-uri` is what `ajv` uses to parse and resolve URIs for `format: "uri"` validation and `$ref` resolution, any host allowlist built on its parsed output could be bypassed. The fix upgrades the resolved copy to `fast-uri` 4.1.4 and pins it with an npm `ove

high

boardcards.js parseArgs() SSRF: --board Flag Reaches Metadata IPs

The `parseArgs()` function in boardcards.js accepted a user-controlled `--board` command-line argument and passed it directly to fetch() calls without validating the URL scheme or hostname. An attacker could supply `http://169.254.169.254/latest/meta-data/` or other internal addresses to extract cloud credentials or scan internal networks.

high

fetch() with Automatic Redirects Leaks HTTPS Trust to api.tomys.top

The `withProxy()` fetch wrapper allowed automatic HTTP redirects from `api.tomys.top`, trusting the external API's domain integrity without validating final destinations. A compromised or malicious redirect response could send users to attacker-controlled phishing sites while maintaining HTTPS protocol and hostname checks that falsely appear secure.

high

Meta.get() Honored @uploadURL From Script Headers: SSRF Fix

A userscript manager's metadata parser accepted the `@uploadURL` directive from a script's own header block, so any installed or auto-updated script could silently redirect the options-page `uploadScript()` fetch to an attacker-chosen destination — including loopback, link-local metadata endpoints, and LAN addresses. The fix makes `Meta.get()` ignore `@uploadURL` when it appears in a script header, leaving the User Metadata field (validated in `Meta.getUserMeta()`) as the only way to set it, and