flawopen.com/ssrf/Javascript
Node.js SSRF: secure http.request and Axios
Node.js SSRF can expose internal services and cloud metadata. Validate via lookup at connect, check IP literals, and disable redirects.
Imagine a dispatch desk that checks a company name in the directory hours before a courier leaves. The name can point somewhere else by departure time. In Node.js, a preflight dns.lookup followed by axios has that gap: axios resolves again and follows redirects by default. The native http.request lookup callback runs as the socket connects, so it can check every resolved address at the point it is used. IP literals skip that callback, which is why net.isIP needs a separate check. ipaddr.process().range() classifies mapped IPv4 and IPv6 addresses; allow only unicast, and do not follow redirect notices.
Core Concepts & Subsystem Terms
String-prefix IP checksaddress.startsWith("10.")and friends miss127.0.0.2,172.16.0.0/12,169.254.169.254,100.64.0.0/10and every IPv6 form such as::ffff:127.0.0.1. Classify addresses with a library instead of string prefixes.Check-then-fetch and DNS rebindingdns.lookupreturns an address at check time, then axios resolves the hostname again when it connects. A DNS server with a short TTL can return a public address first and127.0.0.1second (DNS rebinding), so the check proves nothing about the connection.axios follows redirects- In Node.js axios follows up to five
3xxredirects by default. A public URL that answersLocation: http://169.254.169.254/latest/meta-data/sends the request to the metadata service after the check has passed; setmaxRedirects: 0or use a client that does not follow them. The lookup option of http.requesthttp.requestandhttps.requestaccept a customlookupfunction, which runs when the socket connects, so every address the client actually uses can be checked, with no second resolution. It is not called for IP literals such ashttp://127.0.0.1/, which need a separatenet.isIPcheck.ipaddr.js process() and range()ipaddr.process()parses IPv4 and IPv6 and turns IPv4-mapped IPv6 back into IPv4;range()then names the block (loopback,private,linkLocal,carrierGradeNatand so on), and onlyunicastis a public address.
Framework mappings
- CWE reference for the weakness described in this guide: CWE-918
- OWASP Top 10:2025 — A01:2025 Broken Access Control
- OWASP API Security Top 10 — API7:2023 Server Side Request Forgery
- D3-OTF Outbound Traffic Filtering — This guide's outbound network restriction limits which destinations the service or host can reach.
Step-by-Step Attack Flow
A user supplies a URL
A link-preview or webhook route passes req.body.url to axios.get, axios.post or fetch.
The server connects to an internal address
A DNS precheck does not pin the socket: the HTTP client resolves the host when it connects, so rebinding can change a public answer to a private destination. A custom http.request lookup can check DNS results at connection time; IP literals need a separate net.isIP check.
The internal service answers the trusted caller
Axios follows redirects by default. A public preview or webhook URL can therefore send the backend to 169.254.169.254 after the original URL was checked.
The attacker reads secrets or pivots
The response path creates the disclosure: an Express route that returns Axios response data. Return only the data the feature needs, and keep internal credentials and response bodies out of caller-controlled fetch results.
Source Code: Flaw vs. Secure Implementation
// fetch.js: both routes let the caller choose the destination
const dns = require("node:dns/promises");
const express = require("express");
const axios = require("axios");
const app = express();
app.use(express.json());
// any URL is fetched, and axios follows redirects
app.post("/preview", async (req, res) => {
const { data } = await axios.get(req.body.url, { timeout: 10000 });
res.send(String(data).slice(0, 5000));
});
// three prefixes, one lookup, then axios resolves again
app.post("/webhooks/test", async (req, res) => {
const { hostname } = new URL(req.body.url);
const { address } = await dns.lookup(hostname);
if (address.startsWith("10.") || address.startsWith("192.168.") || address === "127.0.0.1") {
return res.sendStatus(400);
}
const reply = await axios.post(req.body.url, { ping: true }, { timeout: 10000 });
res.json({ status: reply.status });
});
// fetch.js: check every address when the socket connects
const dns = require("node:dns");
const http = require("node:http");
const https = require("node:https");
const net = require("node:net");
const express = require("express");
const ipaddr = require("ipaddr.js");
const app = express();
app.use(express.json());
const MAX_BYTES = 1_000_000;
const isPublic = (address) => ipaddr.process(address).range() === "unicast";
// runs at connect time, so rebinding and redirects cannot slip past
function publicLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
if (addresses.length === 0 || !addresses.every((a) => isPublic(a.address))) {
return callback(new Error(`blocked destination ${hostname}`));
}
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}
// IP literals skip lookup, so they are checked here; no redirects
function fetchPublic(rawUrl, { method = "GET", body } = {}) {
const url = new URL(rawUrl);
if (url.protocol !== "http:" && url.protocol !== "https:") {
return Promise.reject(new Error("only http and https URLs are allowed"));
}
const literal = url.hostname.replace(/^\[|\]$/g, "");
if (net.isIP(literal) && !isPublic(literal)) {
return Promise.reject(new Error("blocked destination"));
}
const client = url.protocol === "https:" ? https : http;
return new Promise((resolve, reject) => {
const headers = body ? { "content-type": "application/json" } : {};
const req = client.request(url, { method, headers, lookup: publicLookup, timeout: 5000 }, (res) => {
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BYTES) req.destroy(new Error("response too large"));
else chunks.push(chunk);
});
res.on("end", () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8") }));
});
req.on("timeout", () => req.destroy(new Error("request timed out")));
req.on("error", reject);
req.end(body);
});
}
// both routes go through fetchPublic
app.post("/preview", async (req, res) => {
try {
const reply = await fetchPublic(req.body.url);
res.send(reply.body.slice(0, 5000));
} catch {
res.sendStatus(400);
}
});
app.post("/webhooks/test", async (req, res) => {
try {
const reply = await fetchPublic(req.body.url, { method: "POST", body: JSON.stringify({ ping: true }) });
res.json({ status: reply.status });
} catch {
res.sendStatus(400);
}
});
Engineering & System Hardening Checklist
- ✓Add a Semgrep rule for Express SSRF (request data reaching
axios,fetch,gotorhttp.request) and review every outbound call that takes a user-supplied URL. - ✓Pass a
lookupfunction that rejects any non-unicastaddress, and check IP-literal hostnames withnet.isIPand the same classifier before connecting. - ✓Allow only
http:andhttps:, do not follow redirects (maxRedirects: 0,redirect: "manual"or plainhttp.request), and set a timeout and a response size cap. - ✓Enforce IMDSv2 with a hop limit of 1, and give the service's cloud role only the permissions it needs.
- ✓Send outbound fetches through an egress proxy or network policy that blocks private, link-local and metadata ranges as a second layer.