CWE-918: Server-Side Request Forgery (SSRF) - JavaScript/Node.js
Overview
Server-Side Request Forgery (SSRF) in JavaScript/Node.js occurs when an application fetches a remote resource from a user-supplied URL without validating the destination. axios, fetch, http, https and request all send the request wherever the URL points, so the destination is whatever the caller supplied: internal services, cloud metadata endpoints (AWS, Azure, GCP), and hosts a perimeter firewall would otherwise keep out of reach.
Primary Defence: Validate URLs against an allowlist of permitted domains, block private and reserved IP ranges by resolving the hostname first, and connect to the address that was checked rather than letting the HTTP client resolve the name a second time.
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:9200/
// Result: reads from an internal Elasticsearch bound to loopback. A port
// speaking something other than HTTP is reachable the same way - the request
// is sent regardless, and a service that parses it as its own protocol acts
// on it - so the destination is the control, not the port number
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 dns = require('dns').promises;
const http = require('http');
const { URL } = require('url');
app.get('/download', async (req, res) => {
const targetUrl = req.query.url;
try {
const parsed = new URL(targetUrl);
// VULNERABLE - the address is checked once, here, and the client
// resolves the name again when it connects
const { address } = await dns.lookup(parsed.hostname);
if (address.startsWith('127.') || address.startsWith('10.') || address.startsWith('192.168.')) {
return res.status(400).json({ error: 'Invalid destination' });
}
// http.get() performs its own lookup: whatever DNS answers *now* is
// where the request goes, and nothing checks that answer
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: evil.com answers 1.2.3.4 to the dns.lookup() above (passes the
// check), then 127.0.0.1 - served with a TTL of 0 - to the lookup http.get() makes
Why this is vulnerable: Time-of-check-time-of-use (TOCTOU): the lookup that was validated and the lookup that is connected to are two different lookups, and an attacker who controls the zone decides what each one returns. The string-prefix test is a second problem - it reads ::1, 169.254.169.254 and 100.64.0.1 as fine - but a complete range list would leave the race exactly where it is.
Deprecated 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.
Library note: request and request-promise are deprecated. Replace them when possible, but keep the same SSRF controls for any HTTP client.
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.
Missing Protocol Restriction
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: Protocol handling depends on the HTTP client, but non-HTTP schemes
// should be blocked before the request is attempted.
Why this is vulnerable: No restriction on URL schemes. Some request libraries or adapters support non-HTTP protocols, and inconsistent protocol handling can create local file or internal service access paths. Public URL fetchers should usually allow only 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}`);
const data = await response.text();
res.send(data);
});
// Attack: /cloud-info?endpoint=169.254.169.254/latest/meta-data/iam/security-credentials/
// Result: the instance role's name, and its temporary credentials one path
// segment further down - on an instance where IMDSv1 is still enabled. With
// IMDSv2 enforced this GET lacks the token header and is refused; the address
// is reached either way. Azure (169.254.169.254/metadata/instance?api-version=2021-02-01)
// and GCP (metadata.google.internal/computeMetadata/v1/) each require a header
// this code does not send, so they answer 4xx here - the request still arrives
Why this is vulnerable: Link-local address 169.254.169.254 and metadata hostnames not blocked.
Secure Patterns
URL Allowlist with axios
const { URL } = require('url');
// 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}`);
}
}
// The framework examples below import this file as './ssrf-protection'
module.exports = { validateUrl };
The caller, as its own file:
const express = require('express');
const axios = require('axios');
const { validateUrl } = require('./ssrf-protection');
const app = express();
app.get('/fetch', async (req, res) => {
try {
const safeUrl = validateUrl(req.query.url);
const response = await axios.get(safeUrl, {
maxRedirects: 0, // Disable redirects
proxy: false // Ignore http_proxy/https_proxy
});
res.json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
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');
const net = require('net');
const http = require('http');
const https = require('https');
const BLOCKED_RANGES = [
'private',
'loopback',
'linkLocal',
'uniqueLocal',
'unspecified',
'broadcast',
'multicast',
'reserved',
'carrierGradeNat',
'deprecatedSiteLocal', // fec0::/10, deprecated in 2004 and still a private range
'discard', // 100::/64, discard-only (RFC 6666) - nothing legitimate lives there
// IPv6 ranges that carry an IPv4 address inside them. ipaddr.js names each
// one separately, so none is covered by the ranges above, and every one of
// them can spell 127.0.0.1 or 169.254.169.254. Only the NAT64 local-use
// prefix 64:ff9b:1::/48 may carry a non-global IPv4 address through a
// compliant translator (RFC 8215); RFC 6052 section 3.1 has the well-known
// prefix form dropped. Refused as encodings, not as proven routes.
'rfc6052', // 64:ff9b::/96 NAT64
'rfc6145', // ::ffff:0:0:0/96 IPv4-translated - one more zero group than the mapped form
'6to4', // 2002::/16
'teredo' // 2001::/32
];
// The IPv4-compatible form: ::7f00:1 is 127.0.0.1 and ::a9fe:a9fe is the
// metadata address, but ipaddr.js ranges both as plain 'unicast' and has no
// predicate for the form. Leave :: and ::1 alone - they are the unspecified
// and loopback addresses, and ::1 would unwrap to 0.0.0.1 and stop looking
// like anything worth blocking.
function unwrapIpv4Compatible(addr) {
const bytes = addr.toByteArray();
if (bytes.slice(0, 12).some((byte) => byte !== 0)) return addr;
const low = bytes.slice(12);
if (low[0] === 0 && low[1] === 0 && low[2] === 0 && low[3] <= 1) return addr;
return ipaddr.fromByteArray(low);
}
function isBlockedAddress(address) {
let addr = ipaddr.parse(address);
// ::ffff:127.0.0.1 is loopback wearing an IPv6 coat. ipaddr.js reports it
// as its own 'ipv4Mapped' range, which is in none of the lists above, so
// it must be unwrapped before the range is classified.
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
addr = addr.toIPv4Address();
} else if (addr.kind() === 'ipv6') {
addr = unwrapIpv4Compatible(addr);
}
return BLOCKED_RANGES.includes(addr.range());
}
// SECURE - Validate both hostname and resolved IPs, and return the addresses
// so the caller can connect to the ones that were checked
async function validateUrlWithIpCheck(urlString) {
const url = new URL(urlString);
// Only allow HTTP/HTTPS
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('Invalid protocol');
}
// Check literal IPs directly; DNS APIs may not return them consistently.
// WHATWG URL keeps the brackets on an IPv6 literal - hostname is '[::1]' -
// and net.isIP('[::1]') is 0, so strip them the way Node's own http layer
// does, or every IPv6 literal takes the DNS path
const host = url.hostname.replace(/^\[(.*)\]$/, '$1');
const literalFamily = net.isIP(host);
const addresses = literalFamily
? [{ address: host, family: literalFamily }]
: await dns.lookup(host, { all: true, verbatim: true });
for (const { address } of addresses) {
if (isBlockedAddress(address)) {
throw new Error(`Host resolves to blocked IP range: ${address}`);
}
}
return { href: url.href, addresses };
}
// See "Custom HTTP Agent with IP Filtering" below for the full version
function pinnedAgent(urlString, { address, family }) {
const Agent = new URL(urlString).protocol === 'https:' ? https.Agent : http.Agent;
// Node calls lookup with { all: true } when autoSelectFamily is on - the
// default since Node 20 - and then expects an array of { address, family }.
// callback(null, address, family) alone fails every request with
// ERR_INVALID_IP_ADDRESS, measured on Node 24.3.0
return new Agent({
lookup: (hostname, options, callback) =>
options.all
? callback(null, [{ address, family }])
: callback(null, address, family)
});
}
// The blocks below require this file as './ssrf-address-policy'
module.exports = { isBlockedAddress, unwrapIpv4Compatible, validateUrlWithIpCheck, pinnedAgent };
The caller, as its own file:
const express = require('express');
const axios = require('axios');
const { validateUrlWithIpCheck, pinnedAgent } = require('./ssrf-address-policy');
const app = express();
app.get('/proxy', async (req, res) => {
try {
const { href, addresses } = await validateUrlWithIpCheck(req.query.url);
// maxRedirects: 0 - nothing re-runs validateUrlWithIpCheck on a Location
// header, so a 302 to 169.254.169.254 would otherwise be followed.
// The pinned lookup is what makes the addresses just validated the ones
// actually connected to; without it axios resolves the hostname again.
// proxy: false - axios reads http_proxy/https_proxy from the environment
// on its own, and a proxied request never resolves the target here: the
// hostname goes to the proxy in the request line and is resolved there.
const agent = pinnedAgent(href, addresses[0]);
const response = await axios.get(href, {
httpAgent: agent,
httpsAgent: agent,
maxRedirects: 0,
proxy: false,
timeout: 5000
});
res.json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
Why this works:
- Literal IPs and DNS names both handled:
net.isIPshort-circuits the lookup for an address that is already numeric, whichdns.lookupdoes not report consistently across platforms - after the brackets are removed, becausenew URL('http://[::1]/').hostnameis'[::1]'andnet.isIPreturns 0 for that - The pinned
lookupanswers in the shape Node asks for: withautoSelectFamilyon, the default since Node 20,netcallslookupwith{ all: true }and expects an array. A lookup that always answerscallback(null, address, family)fails every request withERR_INVALID_IP_ADDRESS- measured on Node 24.3.0 - which looks like the pin working until someone tries the must-allow half - Every candidate address checked, not the first: a hostname with both a public A record and a private one is rejected, rather than depending on which answer the client happens to use
- Every IPv6 form that carries an IPv4 address, and they arrive by three different routes: measured on ipaddr.js 2.5.0,
::ffff:127.0.0.1is rangedipv4Mapped, which is what the first unwrap handles;::7f00:1is ranged plainunicast, which is what the second one handles, and::a9fe:a9fein that form is the metadata address; and64:ff9b::7f00:1,::ffff:0:7f00:1,2002:7f00:1::and a Teredo address get four range names of their own -rfc6052,rfc6145,6to4,teredo- none of them covered byprivateorloopback. Which of them arrives anywhere is a separate question: RFC 6052 section 3.1 requires a translator to drop the well-known prefix around a non-global IPv4 address, and RFC 8215 lifts that for the local-use prefix64:ff9b:1::/48, so64:ff9b:1::7f00:1is the spelling a compliant NAT64 can deliver - ipaddr.js ranges both asrfc6052. They are refused as spellings of a blocked address. The one thing this must not do is over-reach:::ffff:8.8.8.8and64:ff9b:2::1are public addresses and stay allowed - The validated address is the connected address: passing
lookupon the agent removes the second resolution, which is the whole of the DNS rebinding window. Validating and then callingaxios.get(url)leaves it open however thorough the range list is - Redirects rejected rather than followed:
maxRedirects: 0turns a 3xx into an error instead of a second request nobody validated. Where following redirects is required, do it manually - re-runvalidateUrlWithIpCheckon eachLocationand cap the hops, as Redirect Validation Middleware shows - The rejection says nothing back: the handler logs
error.messageand answers a fixedRequest blocked. The messages this code throws name the address the host resolved to -Host resolves to blocked IP range: 10.1.2.3- and axios's own errors carryconnect ECONNREFUSED 10.0.0.5:80orgetaddrinfo ENOTFOUND. Returning either turns a blocked SSRF into a working one for a smaller prize: the attacker cannot fetch the internal service, but they can still resolve internal names and map the network one submission at a time, which is most of what the reconnaissance was for. This is CWE-209 sitting on top of the fix proxy: falsekeeps the pin in the request path: axios picks uphttp_proxy,https_proxyandno_proxyfrom the environment whether or not any code asked for a proxy, so a variable set in a container image is enough. A proxied request connects to the proxy and sends the target as an absolute request-target, so the agent'slookupis called for the proxy's hostname - or, when the proxy is configured as an IP address, not called at all. Either way the target name is resolved by the proxy, where none of the validation ran. Where a proxy is mandatory for egress, the destination control belongs on the proxy
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,
proxy: false
});
res.json(response.data);
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
Why this works: A URL has to clear the domain allowlist and then match a path prefix listed for that domain, so only the named endpoints on an allowed host are reachable. HTTPS is required.
got with Pinned DNS Resolution
// got 14 uses ESM. The policy modules above use CommonJS; save them as .cjs
// when importing them into an ESM project.
import express from 'express';
import got from 'got';
import addressPolicy from './ssrf-address-policy.cjs';
import urlPolicy from './ssrf-protection.cjs';
const { validateUrlWithIpCheck } = addressPolicy;
const { validateUrl } = urlPolicy;
const app = express();
const secureGot = got.extend({
timeout: { request: 5000 },
followRedirect: false,
retry: { limit: 0 }
});
app.get('/fetch', async (req, res) => {
const url = req.query.url;
try {
const { href, addresses } = await validateUrlWithIpCheck(validateUrl(url));
const pinned = addresses[0];
const response = await secureGot(href, {
// SECURE - validation and connection use the same address.
dnsLookup: (hostname, options, callback) => options.all
? callback(null, [pinned])
: callback(null, pinned.address, pinned.family)
});
res.json({ data: response.body });
} catch (error) {
res.status(400).json({ error: 'Request blocked or failed' });
}
});
Why this works: The host allowlist runs first, every resolved address is
checked, and dnsLookup supplies a checked address to the actual connection.
Redirects are disabled and errors returned to the caller are generic.
got-ssrf 3.0.0 does not provide this pin. Its beforeRequest and
beforeRedirect hooks perform a separate lookup and inspect one answer; the
transport then resolves again. A controlled resolver returning a public address
for the hook and loopback for the connection reached a local HTTP listener even
with redirects and retries disabled. A library rejecting private IP literals
does not establish that it prevents DNS rebinding.
Custom HTTP Agent with IP Filtering
const axios = require('axios');
const dns = require('dns').promises;
const http = require('http');
const https = require('https');
const net = require('net');
// One copy of the address policy, in a module both this file and the handlers
// above import - `isBlockedAddress` and its `unwrapIpv4Compatible` helper as
// written under "IP Range Blocking with DNS Resolution". Re-declaring it here
// is how the lists drift, and a range missing from one copy is a bypass.
const { isBlockedAddress } = require('./ssrf-address-policy');
// SECURE - Custom agent that validates IPs before connecting
class SsrfProtectedAgent {
async createAgent(url) {
// Brackets stripped from an IPv6 literal, as in validateUrlWithIpCheck
const hostname = new URL(url).hostname.replace(/^\[(.*)\]$/, '$1');
const literalFamily = net.isIP(hostname);
const addresses = literalFamily
? [{ address: hostname, family: literalFamily }]
: await dns.lookup(hostname, { all: true, verbatim: true });
// Validate all resolved IPs
for (const { address } of addresses) {
if (isBlockedAddress(address)) {
throw new Error(`Blocked IP address: ${address}`);
}
}
const pinned = addresses[0];
// 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 the pre-validated IP, in the shape Node asked for: with
// autoSelectFamily on (the default since Node 20) options.all
// is true and an array of { address, family } is expected
if (options.all) {
callback(null, [pinned]);
} else {
callback(null, pinned.address, pinned.family);
}
}
});
}
}
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,
proxy: false, // or the agent pins the proxy, not the target
timeout: 5000
});
res.json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
Why this works: Custom DNS lookup pins the request to an address that was validated before connection, reducing DNS rebinding risk. Re-resolve and revalidate for each new request, and keep egress firewall controls in place.
proxy: false is what keeps the agent in the path. Axios falls back to http_proxy/https_proxy from the environment when the option is unset, and a proxied request connects to the proxy rather than the target - so lookup runs for the proxy's hostname, or never runs at all if the proxy is configured by IP, and the proxy resolves the target itself. Worth knowing what that looks like when testing: with http_proxy set and no proxy option, a request for http://example.com/target reaches the proxy with the full URL as its request-target and the pinned lookup is not invoked - the fetch succeeds, and nothing in the application logs suggests the address was never checked.
Redirect Validation Middleware
const axios = require('axios');
const { URL } = require('url');
const ALLOWED_DOMAINS = ['api.trusted.com'];
function validateTrustedUrl(urlString) {
const url = new URL(urlString);
if (url.protocol !== 'https:') {
throw new Error('HTTPS required');
}
if (!ALLOWED_DOMAINS.includes(url.hostname)) {
throw new Error('Domain not allowed');
}
return url;
}
async function fetchWithRedirectValidation(urlString, redirectsRemaining = 3) {
const url = validateTrustedUrl(urlString);
const response = await axios.get(url.href, {
maxRedirects: 0, // Handle redirects manually
proxy: false, // A proxy would resolve each hop itself
validateStatus: status => status < 400 || [301, 302, 303, 307, 308].includes(status)
});
// Check if response is a redirect
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (redirectsRemaining === 0) {
throw new Error('Too many redirects');
}
const redirectUrl = new URL(response.headers.location, url.href);
return fetchWithRedirectValidation(redirectUrl.href, redirectsRemaining - 1);
}
return response;
}
app.get('/safe-redirect', async (req, res) => {
try {
const response = await fetchWithRedirectValidation(req.query.url);
res.json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
Why this works: Redirects handled manually. Each redirect target validated against allowlist.
Cloud Metadata Endpoint Blocking
const axios = require('axios');
const { URL } = require('url');
const dns = require('dns').promises;
const net = require('net');
// The same shared module the agent example imports. Nothing here parses an
// address itself, so ipaddr.js is not a dependency of this file.
const { isBlockedAddress, pinnedAgent } = require('./ssrf-address-policy');
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/link-local/private IPs
let addresses;
try {
const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); // '[::1]' -> '::1'
const literalFamily = net.isIP(host);
addresses = literalFamily
? [{ address: host, family: literalFamily }]
: await dns.lookup(host, { all: true, verbatim: true });
} catch (error) {
// DNS resolution failed - block for safety
throw new Error('Cannot resolve hostname');
}
for (const { address } of addresses) {
// Use the shared isBlockedAddress helper rather than an inline
// range list - it unwraps both IPv4-carrying IPv6 forms and covers
// every range, including the ones ipaddr.js names separately
if (isBlockedAddress(address)) {
throw new Error(`Blocked internal or metadata IP range: ${address}`);
}
}
return { href: url.href, addresses };
}
app.get('/cloud-safe', async (req, res) => {
try {
const { href, addresses } = await blockMetadataEndpoints(req.query.url);
// Connect to an address that was just checked. axios.get(href) on its
// own would resolve the name a second time, and that answer - the one
// an attacker with a short TTL controls - is the one nothing checked
const agent = pinnedAgent(href, addresses[0]);
const response = await axios.get(href, {
httpAgent: agent,
httpsAgent: agent,
timeout: 3000,
maxRedirects: 0,
proxy: false
});
res.json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
Why this works: Explicitly blocks cloud metadata hostnames and resolves both literal IPs and DNS names before rejecting metadata, link-local, loopback, private, and reserved ranges - and then connects to an address it checked, through pinnedAgent, so the client never performs a lookup of its own.
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,
maxRedirects: 0,
proxy: false
});
res.json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
Why this works: Only http: and https: pass the allowlist, so file://, ftp:// and gopher:// are rejected along with every other scheme. Note what it does not do: a scheme check says nothing about the destination, so this is one layer of the allowlist-plus-address-check above rather than an alternative to it. maxRedirects: 0 and proxy: false are here because a redirect or an environment proxy would take the request somewhere validateProtocol never saw - a https:// URL that redirects to http://169.254.169.254/ passes the scheme check on the way in.
Key Security Functions
IP Address Validation (ipaddr.js)
const ipaddr = require('ipaddr.js');
// ::7f00:1 is 127.0.0.1 in the IPv4-compatible form and ::a9fe:a9fe is the
// metadata address, but ipaddr.js ranges both as plain 'unicast' and offers no
// predicate for the form. :: and ::1 are left alone - they are the unspecified
// and loopback addresses, and ::1 would unwrap to 0.0.0.1.
function unwrapIpv4Compatible(addr) {
const bytes = addr.toByteArray();
if (bytes.slice(0, 12).some((byte) => byte !== 0)) return addr;
const low = bytes.slice(12);
if (low[0] === 0 && low[1] === 0 && low[2] === 0 && low[3] <= 1) return addr;
return ipaddr.fromByteArray(low);
}
function isPrivateOrSpecialIP(address) {
try {
let addr = ipaddr.parse(address);
// Unwrap both IPv4-carrying forms first - ipaddr.js ranges
// ::ffff:127.0.0.1 as 'ipv4Mapped' and ::7f00:1 as plain 'unicast', so
// without this the loopback check below never sees either
if (addr.kind() === 'ipv6' && addr.isIPv4MappedAddress()) {
addr = addr.toIPv4Address();
} else if (addr.kind() === 'ipv6') {
addr = unwrapIpv4Compatible(addr);
}
const range = addr.range();
// Block internal, metadata, local, and non-routable ranges, plus the
// IPv6 ranges that carry an IPv4 address: ipaddr.js gives each of those
// its own name, so `64:ff9b:1::7f00:1` - a NAT64 spelling of
// 127.0.0.1 - matches none of the ranges on the first two lines
return ['private', 'loopback', 'linkLocal', 'broadcast',
'reserved', 'unspecified', 'uniqueLocal',
'multicast', 'carrierGradeNat', 'deprecatedSiteLocal', 'discard',
'rfc6052', 'rfc6145', '6to4', 'teredo'].includes(range);
} catch (error) {
return true; // Block invalid IPs
}
}
// Usage: isPrivateOrSpecialIP('192.168.1.1') returns true, and the caller
// decides what to do about it - kept out of module scope so requiring this
// file does not throw.
// The block below requires this file as './ip-address-policy'
module.exports = { isPrivateOrSpecialIP };
DNS Resolution and Validation
const dns = require('dns').promises;
const net = require('net');
// isPrivateOrSpecialIP as written above, kept in one module so this file and
// the handlers share a range list rather than each carrying a copy
const { isPrivateOrSpecialIP } = require('./ip-address-policy');
async function resolveAndValidate(hostname) {
const literalFamily = net.isIP(hostname);
const addresses = literalFamily
? [{ address: hostname, family: literalFamily }]
: await dns.lookup(hostname, { all: true, verbatim: true });
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
proxy: false, // Ignore http_proxy/https_proxy from the environment
maxContentLength: 10 * 1024 * 1024, // 10MB max response
validateStatus: status => status < 400
});
Framework-Specific Guidance
Each example below repeats maxRedirects: 0 and proxy: false on the call. That
is deliberate rather than untidy: per-request options do not inherit from
axios.create() defaults unless the request is made through that instance, so a
handler reaching for the bare axios import gets the library defaults - redirects
followed, and http_proxy/https_proxy honoured. Configuring one shared instance
and importing that everywhere is the better shape for real code; these examples
are written standalone so each can be read on its own.
Express.js with axios
const express = require('express');
const axios = require('axios');
const { validateUrl } = require('./ssrf-protection');
const app = express();
app.get('/webhook-test', async (req, res) => {
try {
const safeUrl = await validateUrl(req.query.url);
const response = await axios.post(safeUrl, {
event: 'test',
timestamp: Date.now()
}, {
timeout: 3000,
maxRedirects: 0,
proxy: false
});
res.json({ status: 'sent', response: response.status });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
});
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,
proxy: false
});
res.status(200).json({ data: response.data });
} catch (error) {
console.warn('outbound request rejected', { reason: error.message });
res.status(400).json({ error: 'Request blocked' });
}
}
NestJS Service
import { Injectable, BadRequestException } from '@nestjs/common';
import { HttpService } from '@nestjs/axios';
import { firstValueFrom } from 'rxjs';
import { validateUrl } from './ssrf-protection';
@Injectable()
export class ProxyService {
constructor(private httpService: HttpService) {}
async fetchUrl(url: string): Promise<any> {
try {
const safeUrl = await validateUrl(url);
const response = await firstValueFrom(
this.httpService.get(safeUrl, {
timeout: 5000,
maxRedirects: 0,
proxy: false // @nestjs/axios is axios; same env-proxy default
})
);
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
Testing
- Test normal allowed URLs and paths for each external integration.
- Test blocked destinations such as
localhost,127.0.0.1,[::1], private RFC1918 ranges, link-local169.254.169.254, cloud metadata hostnames, and the ranges a filter written from memory omits:100.64.0.1(CGN),192.0.0.1,198.18.0.1(benchmarking),240.0.0.1,[fc00::1],[fec0::1], the documentation ranges192.0.2.1,198.51.100.1,203.0.113.1and[2001:db8::1], and the discard-only[100::1]. - Test encoded IP forms, decimal/octal/hex variants where the URL parser accepts them, and mixed-case hostnames.
- Test each IPv6 spelling of an IPv4 address as a separate case, because they reach the range list by different routes and a filter can block one and pass the next:
[::ffff:127.0.0.1](mapped),[::7f00:1](compatible),[64:ff9b::7f00:1]and[64:ff9b:1::7f00:1](NAT64),[::ffff:0:7f00:1],[2002:7f00:1::](6to4) and a2001::/32Teredo address. Assert[64:ff9b:1::a9fe:a9fe]and[64:ff9b::a9fe:a9fe]are refused too - the first is the metadata address as a compliant NAT64 can deliver it (RFC 8215 local-use prefix), the second the well-known-prefix form RFC 6052 section 3.1 says a translator must drop. - Assert the must-allow half in the same run, and keep its two halves apart. The address predicate must accept
8.8.8.8,[::ffff:8.8.8.8]and[64:ff9b:2::1]- that last one is a prefix IANA assigns to nobody, so it is a classifier vector and not a URL anything will answer. Then an end-to-end request to a controlled, allowlisted HTTPS origin must return 200 through the pinned agent. A filter that blocks everything passes every block test. - Test redirects from an allowed public host to an internal address and confirm every redirect target is revalidated or redirects are disabled.
- Test DNS rebinding-style cases in a controlled environment and confirm connection-time address validation or DNS pinning is effective.
- Retest with SAST/DAST and review outbound logs to confirm invalid requests are rejected before any network connection is made.
Common Pitfalls
- Blocking only string patterns such as
localhostor127.0.0.1. - Validating DNS once and then letting the HTTP client resolve the hostname again differently.
- Pinning the connection with an agent
lookupand leavingproxyunset - axios useshttp_proxy/https_proxyfrom the environment by default, and a proxied request resolves the target at the proxy, so the pinned lookup either runs against the proxy's hostname or is skipped entirely.proxy: falseis what disables that. - Returning the validation error to the caller. A message naming the resolved address, or an
ECONNREFUSED 10.0.0.5:80from the client, answers the question the SSRF was asked to answer - the fetch is blocked and the network map still leaks, one hostname per request. Log the detail and reply with a fixed string. - Allowing redirects without validating the redirect destination.
- Allowing user control over only the hostname and assuming fixed prefixes or suffixes make the URL safe.
- Blocking private IPv4 ranges but forgetting IPv6, link-local, unique-local, metadata hostnames, and reserved ranges.
- Relying on application validation without egress controls for high-risk environments.
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)
- Block the deprecated site-local range
fec0::/10, which ipaddr.js namesdeprecatedSiteLocaland which still turns up on older internal networks - Block the documentation ranges and the discard-only
100::/64- ipaddr.js files the first underreservedand the second underdiscard, and neither is a place a legitimate request goes - Block every IPv6 form that carries an IPv4 address, and note they need three different mechanisms:
::ffff:127.0.0.1(mapped) has an ipaddr.js predicate,::7f00:1(compatible) has none and ranges as plainunicast, andrfc6052,rfc6145,6to4andteredoeach get a range name of their own - 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
Dependencies and Installation
- Use the platform URL parser (
URL) rather than ad hoc string parsing. - Use
ipaddr.jsor an equivalent maintained IP parser for IPv4/IPv6 range classification. - Prefer maintained HTTP clients with timeout, redirect, size-limit, and agent customization support.
- Treat deprecated packages such as
requestandrequest-promiseas migration targets. - Keep SSRF helper packages, HTTP clients, and DNS-related dependencies current, and pair them with infrastructure egress controls.
Additional Resources
- got DNS lookup option
- ipaddr.js Library
- Node.js
net.connectoptions - thelookupandautoSelectFamilyoptions the pinned agents depend on, and why a custom lookup must honourall - OWASP SSRF Prevention Cheat Sheet
- PortSwigger SSRF Guide