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.
사무실 배달원에게 일반 상점에서 소포를 찾아오라고 하면서, 사장실 안의 잠긴 금고 주소를 적어주는 상황을 상상해 보세요. 배달원은 사내 출입증을 소지하고 있으므로 경비원을 통과해 금고를 열고 회사 기밀을 넘겨주게 됩니다.
Web Application SecurityCWE-918.CWE-918CWE-918): Standard Common Weakness Enumeration classification for ssrf-javascript.Defense-in-Depth애플리케이션이 웹훅 전송, 이미지 미리보기 등을 위해 사용자가 제출한 URL을 수신합니다.
공격자가 루프백 주소나 클라우드 메타데이터 URL(예: http://169.254.169.254/latest/meta-data/)을 지정합니다.
서버의 HTTP 클라이언트가 사설 VPC 내부에서 IP 범위 검증 없이 요청을 전송합니다.
내부 메타데이터 서비스가 요청을 신뢰하여 임시 IAM 보안 자격 증명 및 관리자 데이터를 반환합니다.
// 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);
}
});