CWE-918: Server-Side Request Forgery (SSRF) - JavaScript/Node.js
Overview
Server-Side Request Forgery (SSRF) in JavaScript/Node.js occurs when applications fetch remote resources based on user-supplied URLs without proper validation. Node.js applications are particularly vulnerable when using axios, fetch, http, https, or request libraries with user-controlled URLs. SSRF enables attackers to access internal services, cloud metadata endpoints (AWS, Azure, GCP), and bypass firewalls.
Primary Defence: Validate URLs against an allowlist of permitted domains, block private/reserved IP ranges using DNS resolution checks, and use libraries like validator with custom URL validation.
Common Vulnerable Patterns
axios with User-Controlled URL
const express = require('express');
const axios = require('axios');
const app = express();
app.get('/fetch', async (req, res) => {
const url = req.query.url;
try {
// VULNERABLE - No URL validation
const response = await axios.get(url);
res.json({ data: response.data });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Attack: /fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
// Result: AWS IAM credentials leaked to attacker
Why this is vulnerable: No validation of URL destination. Attacker can access internal metadata endpoints.
fetch API with URL Concatenation
app.get('/proxy', async (req, res) => {
const domain = req.query.domain;
// VULNERABLE - URL construction with user input
const url = `https://${domain}/api/data`;
try {
const response = await fetch(url);
const data = await response.json();
res.json(data);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Attack: /proxy?domain=localhost:6379/
// Result: Access to internal Redis server (if HTTP interface exposed)
Why this is vulnerable: User controls the domain portion, can specify internal hosts like localhost, 192.168.1.1, etc.
http.get with DNS Rebinding Bypass
const http = require('http');
const { URL } = require('url');
app.get('/download', (req, res) => {
const targetUrl = req.query.url;
try {
const parsed = new URL(targetUrl);
// VULNERABLE - Single DNS resolution check
if (parsed.hostname === 'localhost' || parsed.hostname.startsWith('192.168.')) {
return res.status(400).json({ error: 'Invalid hostname' });
}
// DNS rebinding attack: Domain initially resolves to external IP,
// then changes to 127.0.0.1 between validation and request
http.get(targetUrl, (response) => {
let data = '';
response.on('data', chunk => data += chunk);
response.on('end', () => res.send(data));
});
} catch (error) {
res.status(400).json({ error: 'Invalid URL' });
}
});
// Attack: Domain evil.com resolves to 1.2.3.4 during check,
// then attacker changes DNS to point to 127.0.0.1 before request
Why this is vulnerable: Time-of-check-time-of-use (TOCTOU) vulnerability. DNS can change between validation and request.
request-promise with Insufficient Validation
const request = require('request-promise');
app.post('/webhook', async (req, res) => {
const webhookUrl = req.body.callback_url;
// VULNERABLE - Weak denylist
if (webhookUrl.includes('localhost') || webhookUrl.includes('127.0.0.1')) {
return res.status(400).json({ error: 'Invalid URL' });
}
try {
const result = await request({
url: webhookUrl,
method: 'POST',
json: { event: 'completed', timestamp: Date.now() }
});
res.json({ status: 'Webhook sent' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Bypass attacks:
// - http://[::1]/ (IPv6 localhost)
// - http://127.1/ (shorthand for 127.0.0.1)
// - http://0177.0.0.1/ (octal notation)
// - http://0x7f.0.0.1/ (hex notation)
// - http://2130706433/ (decimal notation for 127.0.0.1)
Why this is vulnerable: Denylists are easily bypassed using IP encoding variations, IPv6, or DNS resolution.
got Library Without IP Validation
const got = require('got');
app.get('/image', async (req, res) => {
const imageUrl = req.query.url;
try {
// VULNERABLE - No IP range checking
const response = await got(imageUrl, {
responseType: 'buffer',
timeout: 5000
});
res.set('Content-Type', response.headers['content-type']);
res.send(response.body);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Attack: /image?url=http://192.168.1.1/admin
// Result: Access to internal router admin interface
Why this is vulnerable: No check for private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16).
URL Redirect Following
const axios = require('axios');
app.get('/proxy', async (req, res) => {
const url = req.query.url;
// VULNERABLE - Follows redirects without re-validation
try {
const response = await axios.get(url, {
maxRedirects: 5 // Follows up to 5 redirects
});
res.json({ data: response.data });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Attack: /proxy?url=https://evil.com/redirect
// evil.com redirects to http://169.254.169.254/latest/meta-data/
// Result: Cloud metadata accessed via redirect
Why this is vulnerable: Initial URL may pass validation, but redirect target is not checked.
File Protocol Access
const axios = require('axios');
app.get('/fetch', async (req, res) => {
const url = req.query.url;
try {
// VULNERABLE - No protocol restriction
const response = await axios.get(url);
res.send(response.data);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Attack: /fetch?url=file:///etc/passwd
// Result: Local file disclosure (if axios supports file:// protocol)
Why this is vulnerable: No restriction on URL schemes. Should only allow http:// and https://.
Cloud Metadata Endpoint Access
const fetch = require('node-fetch');
app.get('/cloud-info', async (req, res) => {
const endpoint = req.query.endpoint;
// VULNERABLE - No blocking of metadata IPs
const response = await fetch(`http://${endpoint}/info`);
const data = await response.text();
res.send(data);
});
// Attack: /cloud-info?endpoint=169.254.169.254/latest/meta-data
// Result: AWS metadata with IAM role credentials exposed
// Similar attacks work for:
// - Azure: 169.254.169.254/metadata/instance
// - GCP: metadata.google.internal/computeMetadata/v1/
Why this is vulnerable: Link-local address 169.254.169.254 and metadata hostnames not blocked.
Secure Patterns
URL Allowlist with axios
const express = require('express');
const axios = require('axios');
const { URL } = require('url');
const app = express();
// SECURE - Explicit allowlist of permitted domains
const ALLOWED_DOMAINS = [
'api.github.com',
'api.example.com',
'webhook.example.com'
];
function validateUrl(urlString) {
try {
const url = new URL(urlString);
// Require HTTPS
if (url.protocol !== 'https:') {
throw new Error('Only HTTPS URLs allowed');
}
// Check against allowlist
if (!ALLOWED_DOMAINS.includes(url.hostname)) {
throw new Error(`Domain ${url.hostname} not in allowlist`);
}
return url.href;
} catch (error) {
throw new Error(`Invalid URL: ${error.message}`);
}
}
app.get('/fetch', async (req, res) => {
try {
const safeUrl = validateUrl(req.query.url);
const response = await axios.get(safeUrl, {
maxRedirects: 0 // Disable redirects
});
res.json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Only pre-approved domains allowed. HTTPS required. Redirects disabled.
IP Range Blocking with DNS Resolution
const dns = require('dns').promises;
const { URL } = require('url');
const ipaddr = require('ipaddr.js');
// SECURE - Validate both hostname and resolved IPs
async function validateUrlWithIpCheck(urlString) {
const url = new URL(urlString);
// Only allow HTTP/HTTPS
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('Invalid protocol');
}
// Resolve DNS and check all IPs
const addresses = await dns.resolve(url.hostname);
for (const address of addresses) {
const addr = ipaddr.parse(address);
// Block private IP ranges
if (addr.range() === 'private') {
throw new Error(`Host resolves to private IP: ${address}`);
}
// Block loopback
if (addr.range() === 'loopback') {
throw new Error('Loopback addresses not allowed');
}
// Block link-local (metadata endpoints)
if (addr.range() === 'linkLocal') {
throw new Error('Link-local addresses not allowed');
}
}
return url.href;
}
app.get('/proxy', async (req, res) => {
try {
const safeUrl = await validateUrlWithIpCheck(req.query.url);
const response = await axios.get(safeUrl);
res.json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Performs DNS resolution and validates all IPs against private ranges, loopback, and link-local.
Domain Allowlist with Path Restriction
const { URL } = require('url');
const axios = require('axios');
const ALLOWED_APIS = {
'api.github.com': ['/repos/', '/users/'],
'api.example.com': ['/public/']
};
function validateApiUrl(urlString) {
const url = new URL(urlString);
// Check domain allowlist
const allowedPaths = ALLOWED_APIS[url.hostname];
if (!allowedPaths) {
throw new Error('Domain not allowed');
}
// Check path allowlist
const pathAllowed = allowedPaths.some(prefix =>
url.pathname.startsWith(prefix)
);
if (!pathAllowed) {
throw new Error('Path not allowed');
}
// Require HTTPS
if (url.protocol !== 'https:') {
throw new Error('HTTPS required');
}
return url.href;
}
app.get('/api-proxy', async (req, res) => {
try {
const safeUrl = validateApiUrl(req.query.url);
const response = await axios.get(safeUrl, {
timeout: 5000,
maxRedirects: 0
});
res.json(response.data);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Validates both domain AND path. Only specific API endpoints allowed.
Network Segmentation with got-ssrf
const got = require('got');
const { gotSsrfPlugin } = require('got-ssrf');
// SECURE - Use got-ssrf plugin to block SSRF attempts
const secureGot = got.extend({
hooks: {
beforeRequest: [
gotSsrfPlugin({
// Block private IPs
allowPrivateIPAddresses: false,
// Block metadata endpoints
allowMetaIPAddresses: false
})
]
},
timeout: {
request: 5000
},
maxRedirects: 0
});
app.get('/fetch', async (req, res) => {
const url = req.query.url;
try {
const response = await secureGot(url);
res.json({ data: response.body });
} catch (error) {
res.status(400).json({ error: 'Request blocked or failed' });
}
});
Why this works: got-ssrf plugin automatically blocks private IPs and metadata endpoints.
Custom HTTP Agent with IP Filtering
const axios = require('axios');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');
const http = require('http');
const https = require('https');
// SECURE - Custom agent that validates IPs before connecting
class SsrfProtectedAgent {
async createAgent(url) {
const hostname = new URL(url).hostname;
const addresses = await dns.resolve(hostname);
// Validate all resolved IPs
for (const address of addresses) {
const addr = ipaddr.parse(address);
if (['private', 'loopback', 'linkLocal'].includes(addr.range())) {
throw new Error(`Blocked IP range: ${addr.range()}`);
}
}
// Create agent that uses validated DNS result
const isHttps = url.startsWith('https');
const Agent = isHttps ? https.Agent : http.Agent;
return new Agent({
lookup: (hostname, options, callback) => {
// Use pre-validated IP
callback(null, addresses[0], 4);
}
});
}
}
app.get('/secure-fetch', async (req, res) => {
const url = req.query.url;
try {
const agentHelper = new SsrfProtectedAgent();
const agent = await agentHelper.createAgent(url);
const response = await axios.get(url, {
httpAgent: agent,
httpsAgent: agent,
maxRedirects: 0,
timeout: 5000
});
res.json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Custom DNS lookup prevents DNS rebinding. IPs validated before connection.
Redirect Validation Middleware
const axios = require('axios');
const { URL } = require('url');
const ALLOWED_DOMAINS = ['api.trusted.com'];
async function fetchWithRedirectValidation(url) {
const response = await axios.get(url, {
maxRedirects: 0, // Handle redirects manually
validateStatus: status => status < 400 || status === 302 || status === 301
});
// Check if response is a redirect
if (response.status === 301 || response.status === 302) {
const redirectUrl = response.headers.location;
const redirectHost = new URL(redirectUrl, url).hostname;
// SECURE - Validate redirect target
if (!ALLOWED_DOMAINS.includes(redirectHost)) {
throw new Error('Redirect to unauthorized domain blocked');
}
// Recursively fetch with validation
return fetchWithRedirectValidation(redirectUrl);
}
return response;
}
app.get('/safe-redirect', async (req, res) => {
try {
const url = new URL(req.query.url);
if (!ALLOWED_DOMAINS.includes(url.hostname)) {
return res.status(400).json({ error: 'Domain not allowed' });
}
const response = await fetchWithRedirectValidation(url.href);
res.json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Redirects handled manually. Each redirect target validated against allowlist.
Cloud Metadata Endpoint Blocking
const { URL } = require('url');
const dns = require('dns').promises;
const ipaddr = require('ipaddr.js');
const METADATA_HOSTNAMES = [
'metadata.google.internal',
'metadata',
'169.254.169.254'
];
async function blockMetadataEndpoints(urlString) {
const url = new URL(urlString);
// Block metadata hostnames
if (METADATA_HOSTNAMES.includes(url.hostname.toLowerCase())) {
throw new Error('Metadata endpoint blocked');
}
// Resolve and check for metadata IP
try {
const addresses = await dns.resolve(url.hostname);
for (const address of addresses) {
// Block 169.254.0.0/16 (link-local, used by cloud metadata)
if (address.startsWith('169.254.')) {
throw new Error('Metadata IP range blocked');
}
}
} catch (error) {
if (error.message.includes('blocked')) {
throw error;
}
// DNS resolution failed - block for safety
throw new Error('Cannot resolve hostname');
}
return url.href;
}
app.get('/cloud-safe', async (req, res) => {
try {
const safeUrl = await blockMetadataEndpoints(req.query.url);
const response = await axios.get(safeUrl, {
timeout: 3000,
maxRedirects: 0
});
res.json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Explicitly blocks cloud metadata hostnames and link-local IP range.
Protocol Restriction
const { URL } = require('url');
const axios = require('axios');
const ALLOWED_PROTOCOLS = ['http:', 'https:'];
function validateProtocol(urlString) {
const url = new URL(urlString);
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
throw new Error(`Protocol ${url.protocol} not allowed`);
}
return url;
}
app.get('/fetch', async (req, res) => {
try {
const url = validateProtocol(req.query.url);
// Additional validation here (domain allowlist, etc.)
const response = await axios.get(url.href, {
timeout: 5000
});
res.json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Why this works: Blocks file://, ftp://, gopher://, and other dangerous protocols.
Key Security Functions
IP Address Validation (ipaddr.js)
const ipaddr = require('ipaddr.js');
function isPrivateOrSpecialIP(address) {
try {
const addr = ipaddr.parse(address);
const range = addr.range();
// Block: private, loopback, linkLocal, broadcast, reserved
return ['private', 'loopback', 'linkLocal', 'broadcast',
'reserved', 'unspecified'].includes(range);
} catch (error) {
return true; // Block invalid IPs
}
}
// Usage:
if (isPrivateOrSpecialIP('192.168.1.1')) {
throw new Error('Private IP blocked');
}
DNS Resolution and Validation
const dns = require('dns').promises;
async function resolveAndValidate(hostname) {
const addresses = await dns.resolve(hostname);
for (const address of addresses) {
if (isPrivateOrSpecialIP(address)) {
throw new Error(`Host resolves to blocked IP: ${address}`);
}
}
return addresses;
}
URL Normalization
const { URL } = require('url');
function normalizeUrl(urlString) {
const url = new URL(urlString);
// Remove credentials if present
url.username = '';
url.password = '';
// Normalize hostname (lowercase)
url.hostname = url.hostname.toLowerCase();
// Remove fragment
url.hash = '';
return url.href;
}
Timeout Configuration
const axios = require('axios');
const secureAxios = axios.create({
timeout: 5000, // 5 second timeout
maxRedirects: 0, // Disable automatic redirects
maxContentLength: 10 * 1024 * 1024, // 10MB max response
validateStatus: status => status < 400
});
Testing and Validation
SSRF vulnerabilities should be identified through:
- Static Analysis Tools: Use tools like ESLint with security plugins, Semgrep, SonarQube, or Snyk to identify potential SSRF sinks
- Dynamic Application Security Testing (DAST): Tools like OWASP ZAP, Burp Suite, or Acunetix can test for SSRF by manipulating URL parameters
- Manual Penetration Testing: Test with internal IP addresses (127.0.0.1, 192.168.x.x), cloud metadata endpoints (169.254.169.254), and file:// protocols
- Code Review: Ensure all HTTP client usage includes URL validation against an allowlist and blocks private IP ranges
- Network Monitoring: Monitor outbound requests to detect unexpected internal network access
Framework-Specific SSRF Patterns
Express.js with axios
const express = require('express');
const axios = require('axios');
const { validateSafeUrl } = require('./ssrf-protection');
const app = express();
app.get('/webhook-test', async (req, res) => {
try {
const safeUrl = await validateSafeUrl(req.query.url);
const response = await axios.post(safeUrl, {
event: 'test',
timestamp: Date.now()
}, {
timeout: 3000,
maxRedirects: 0
});
res.json({ status: 'sent', response: response.status });
} catch (error) {
res.status(400).json({ error: error.message });
}
});
Next.js API Routes
// pages/api/fetch.js
import axios from 'axios';
import { validateUrl } from '../../lib/ssrf-protection';
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const safeUrl = await validateUrl(req.query.url);
const response = await axios.get(safeUrl, {
timeout: 5000,
maxRedirects: 0
});
res.status(200).json({ data: response.data });
} catch (error) {
res.status(400).json({ error: error.message });
}
}
NestJS Service
import { Injectable, BadRequestException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { validateSafeUrl } from './ssrf-protection';
@Injectable()
export class ProxyService {
constructor(private httpService: HttpService) {}
async fetchUrl(url: string): Promise<any> {
try {
const safeUrl = await validateSafeUrl(url);
const response = await firstValueFrom(
this.httpService.get(safeUrl, {
timeout: 5000,
maxRedirects: 0
})
);
return response.data;
} catch (error) {
throw new BadRequestException('Invalid or blocked URL');
}
}
}
Typical SSRF Findings
-
"User-controlled URL in HTTP request"
- Location:
axios.get(req.query.url) - Fix: Implement URL allowlist validation before making request
- Location:
-
"Server-Side Request Forgery via unvalidated URL"
- Location:
fetch(userSuppliedUrl) - Fix: Add domain allowlist and IP range validation
- Location:
-
"Potential access to cloud metadata endpoint"
- Location: Request to user-controlled destination without blocking 169.254.169.254
- Fix: Explicitly block link-local IP range and metadata hostnames
-
"DNS rebinding vulnerability"
- Location: Single DNS check followed by request
- Fix: Pin DNS resolution using custom HTTP agent
-
"Unrestricted URL redirect following"
- Location:
maxRedirects: 5with user-controlled initial URL - Fix: Disable redirects or validate each redirect target
- Location:
-
"File protocol SSRF"
- Location: Accepting
file://URLs in fetch operations - Fix: Restrict allowed protocols to
http:andhttps:only
- Location: Accepting
Defense in Depth
Layer 1: URL Allowlist (Primary)
- Maintain explicit list of permitted domains
- Validate scheme (HTTPS only when possible)
- Validate path if needed
- Use exact hostname matching
Layer 2: IP Range Blocking (Secondary)
- Block private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
- Block loopback: 127.0.0.0/8, ::1
- Block link-local: 169.254.0.0/16 (cloud metadata)
- Use
ipaddr.jsfor reliable IP parsing
Layer 3: DNS Validation (Tertiary)
- Resolve DNS and validate all IPs
- Pin DNS resolution to prevent rebinding
- Re-validate before actual request
Layer 4: Network Controls (Infrastructure)
- Implement egress firewall rules
- Use network segmentation
- Deploy in VPC with restricted outbound access
- Monitor outbound connections
Security Checklist
- Implement URL allowlist (domain and optionally path)
- Block private IP ranges using
ipaddr.js - Block loopback addresses (127.0.0.0/8, ::1)
- Block link-local range (169.254.0.0/16) for cloud metadata protection
- Restrict protocols to
http:andhttps:only - Disable automatic redirect following or validate each redirect
- Implement DNS resolution with IP validation
- Use DNS pinning via custom HTTP agent
- Set request timeouts (3-5 seconds recommended)
- Set maximum response size limits
- Never trust Host header for internal requests
- Monitor and log all outbound requests
- Test with SSRF payloads: localhost, 169.254.169.254, file://, redirects