Overview
SSRF risk in Node.js/Express arises when user-supplied URLs are fetched or dereferenced by the server. The CVE-2026-25534 incident shows how validation can fail when parsing can be bypassed by edge cases; while that exploit involved Java URL parsing, the core lesson transfers: validation gaps enable an attacker to coerce the server into contacting arbitrary hosts. This vulnerability is related to CWE-918, and the broader context includes CVE-2025-61916, which Spinnaker previously attempted to mitigate but could still be circumvented with crafted URLs. For Node.js apps, this means carefully validating and constraining any operation that touches remote resources based on user input to prevent the server from reaching unintended endpoints. Monitoring and defense-in-depth are essential to reduce exposure from SSRF class flaws in Express routes.
Affected Versions
2025.2.4, 2025.3.1, 2025.4.1, 2026.0.0
Code Fix Example
Node.js (Express) API Security Remediation
// Vulnerable
app.post('/fetch', async (req, res) => {
const url = req.body.url;
const r = await fetch(url); // no validation or restrictions
res.status(r.ok ? 200 : r.status).send(await r.text());
});
// Fixed
const ALLOWED_HOSTS = ['example.com', 'api.example.com'];
const { URL } = require('url');
const dns = require('dns').promises;
const { AbortController } = require('abort-controller'); // if needed in older Node.js; otherwise use global AbortController
function isPrivateIp(ip) {
const parts = ip.split('.').map(n => parseInt(n, 10));
if (parts.length !== 4 || parts.some(n => Number.isNaN(n))) return true;
const [a, b] = parts;
if (a === 10) return true;
if (a === 172 && b >= 16 && b <= 31) return true;
if (a === 192 && parts[1] === 168) return true;
if (a === 127) return true;
return false;
}
async function isPrivateHost(hostname) {
try {
const addrs = await dns.lookup(hostname, { all: true });
for (const a of addrs) {
if (isPrivateIp(a.address)) return true;
}
} catch {
// If DNS resolution fails, err on the side of caution
return true;
}
return false;
}
app.post('/fetch', async (req, res) => {
const urlStr = req.body?.url;
if (!urlStr) return res.status(400).send('url is required');
let u;
try { u = new URL(urlStr); } catch {
return res.status(400).send('invalid URL');
}
if (!['http:', 'https:'].includes(u.protocol)) return res.status(400).send('unsupported URL scheme');
if (!ALLOWED_HOSTS.includes(u.hostname)) return res.status(403).send('host not allowed');
if (await isPrivateHost(u.hostname)) return res.status(400).send('private or internal host not allowed');
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 5000);
try {
const resp = await fetch(urlStr, { signal: ctrl.signal });
clearTimeout(t);
res.status(resp.ok ? 200 : resp.status).send(await resp.text());
} catch (err) {
res.status(500).send('failed to fetch URL');
}
});