FlawOpen

flawopen.com/ssrf/Javascript

● CWE-918 · High
FlawOpen Security Research

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.

💡 Plain English Explainer (ELI5)

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 checks
address.startsWith("10.") and friends miss 127.0.0.2, 172.16.0.0/12, 169.254.169.254, 100.64.0.0/10 and every IPv6 form such as ::ffff:127.0.0.1. Classify addresses with a library instead of string prefixes.
Check-then-fetch and DNS rebinding
dns.lookup returns 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 and 127.0.0.1 second (DNS rebinding), so the check proves nothing about the connection.
axios follows redirects
In Node.js axios follows up to five 3xx redirects by default. A public URL that answers Location: http://169.254.169.254/latest/meta-data/ sends the request to the metadata service after the check has passed; set maxRedirects: 0 or use a client that does not follow them.
The lookup option of http.request
http.request and https.request accept a custom lookup function, 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 as http://127.0.0.1/, which need a separate net.isIP check.
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, carrierGradeNat and so on), and only unicast is a public address.

Framework mappings

Step-by-Step Attack Flow

Step 1

A user supplies a URL

A link-preview or webhook route passes req.body.url to axios.get, axios.post or fetch.

Step 2

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.

Step 3

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.

Step 4

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

UNPATCHED FLAW
// 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 });
});
HARDENED SECURE PATCH
// 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

Sources