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 endpointhttps://192.168.1.1/admin/— Internal router admin panelhttps://10.0.0.5:5432/— Internal database servicehttps://127.0.0.1:8080/internal-api/— Localhost-bound serviceshttps://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:
- Hostname resolution — Convert the URL's hostname to an IP address
- Private IP detection — Check if the resolved IP is in private/reserved ranges
- 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
-
const dns = require('dns').promises— Imports Node.js's DNS resolution module. We use the promise-based API for cleaner async/await syntax. -
const net = require('net')— Imports Node.js's network utilities, specificallynet.isIPv4()for checking if a string is a valid IPv4 address. -
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 bya === 10)
- 127.0.0.0/8 — Loopback/localhost (detected bya === 127)
- 0.0.0.0/8 — "This" network (detected bya === 0)
- 169.254.0.0/16 — Link-local/metadata endpoints (detected bya === 169 && b === 254)
- 172.16.0.0/12 — Private range (detected bya === 172 && b >= 16 && b <= 31)
- 192.168.0.0/16 — Private range (detected bya === 192 && b === 168)
- IPv6 equivalents — Loopback (::1), link-local (fe80::/10), and unique local (fc00::/7) -
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. -
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. -
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
-
Always validate URL destinations, not just formats — Checking URL format is necessary but insufficient. Always resolve hostnames and validate the target IP.
-
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.
-
Use established libraries — Consider using libraries like
node-fetch-retryoraxioswith interceptors that can enforce SSRF protections consistently. -
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.
-
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://orhttps://, 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.