flawopen.com/ssrf/Javascript
How axios and fetch allow internal network scanning and cloud metadata theft, and how to implement safe lookup agents in Node.js.
Imagina enviar a un mensajero de oficina a recoger un paquete a una tienda pública, pero dándole la dirección de la caja fuerte del despacho del director. Como el mensajero tiene pase interno, entra al despacho, abre la caja y te entrega secretos de la empresa.
Web Application SecurityCWE-918.CWE-918Defense-in-DepthLa aplicación acepta una URL de usuario para descargar imágenes, webhooks o previsualizar documentos.
El atacante suministra una URL apuntando a la dirección de metadatos de la nube o loopback (ej. http://169.254.169.254/latest/meta-data/).
El cliente HTTP del servidor inicia la conexión desde la red privada interna sin validar si la IP pertenece a rangos reservados.
El servicio de metadados responde a la solicitud confiable entregando credenciales de IAM o tokens de seguridad.
// VULNERABLE: Naively fetching user URL with axios
const express = require('express');
const axios = require('axios');
const app = express();
app.get('/proxy-image', async (req, res) => {
const imageUrl = req.query.url;
// Attacker input: http://169.254.169.254/latest/meta-data/
try {
const response = await axios.get(imageUrl);
res.send(response.data);
} catch (err) {
res.status(500).send('Fetch error');
}
});
// HARDENED: Custom DNS lookup agent blocking private & metadata IP ranges
const express = require('express');
const http = require('http');
const https = require('https');
const dns = require('dns');
const ipaddr = require('ipaddr.js'); // npm i ipaddr.js
const axios = require('axios');
const app = express();
function safeLookup(hostname, options, callback) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err);
try {
const addr = ipaddr.parse(address);
const range = addr.range();
// Deny private, loopback, link-local, and reserved ranges
const blockedRanges = ['loopback', 'private', 'linkLocal', 'carrierGradeNat', 'reserved'];
if (blockedRanges.includes(range)) {
return callback(new Error(`SSRF Blocked: Destination IP ${address} is in blocked range (${range})`));
}
callback(null, address, family);
} catch (parseErr) {
callback(new Error('Invalid IP address resolved'));
}
});
}
const safeHttpAgent = new http.Agent({ lookup: safeLookup });
const safeHttpsAgent = new https.Agent({ lookup: safeLookup });
app.get('/proxy-image', async (req, res) => {
const imageUrl = req.query.url;
try {
const parsed = new URL(imageUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).send('Invalid protocol');
}
const response = await axios.get(imageUrl, {
httpAgent: safeHttpAgent,
httpsAgent: safeHttpsAgent,
maxRedirects: 0, // Prevent redirect bypasses
timeout: 3000
});
res.send(response.data);
} catch (err) {
res.status(403).send('Request blocked: ' + err.message);
}
});