SSRF(Server-Side Request Forgery,服务器端请求伪造)在Node.js后端中之所以危险,是因为Node.js天生擅长发起网络请求,许多应用依赖远程资源、Webhook回调、图片代理或URL抓取功能,而这些功能一旦对用户输入的URL校验不严,就会变成攻击者探测内网、读取云元数据、攻击内部服务的跳板。攻击者传入一个看似无害的URL,你的服务器却替他去访问了不该访问的地址,而且请求源IP还是你服务器的可信IP。
Node.js中SSRF的典型触发场景最容易出现SSRF的地方,是那些“用户给URL,服务端去请求”的功能。比如用户头像远程加载、网页截图服务、RSS订阅解析、PDF导出中的外链图片、Webhook地址配置、URL预览、文件导入远程地址等。代码里常见的写法是直接拿用户输入拼进http.get、axios、fetch、request或got等请求库,没有做任何地址解析和限制。
const url = req.query.url; const response = await axios.get(url); res.send(response.data);
这段代码中,攻击者可以传入http://169.254.169.254/latest/meta-data/,如果部署在云环境,就可能直接拿到实例的IAM临时凭证。也可以传入http://127.0.0.1:6379/,尝试探测本机Redis。更隐蔽的是利用302重定向绕过初层校验,先请求一个白名单域名,再由该域名跳转到内网地址。
第一道防线:严格解析和校验URL不要用正则去判断URL是否安全,正则对URL的解析非常不可靠。攻击者可以用十进制IP、八进制IP、十六进制IP、IPv6地址、用户信息混淆、短链接跳转、DNS重绑定等方式绕过。正确做法是使用Node.js内置的URL类或第三方库解析出hostname,然后对hostname做白名单或黑名单校验。
const { URL } = require('url');
function validateUrl(input) {
let parsed;
try {
parsed = new URL(input);
} catch (err) {
return false;
}
const allowedHosts = ['api.example.com', 'cdn.example.com'];
return allowedHosts.includes(parsed.hostname);
}
这里必须用hostname而不是host,因为host会带上端口,而hostname不包含端口。同时要检查protocol,只允许http和https。还要注意URL类对某些畸形输入的解析结果,比如http://example.com@127.0.0.1/,hostname是127.0.0.1,但用户看到的可能是example.com。所以解析后必须基于解析结果判断,而不是基于原始字符串。
DNS解析与IP地址二次校验即使hostname在白名单中,也不能完全放心。攻击者可以控制DNS记录,把白名单域名解析到内网IP。这就是DNS重绑定攻击。因此,在发起请求前,需要把hostname解析成IP地址,再对IP地址做私网段、回环地址、链路本地地址、保留地址的检查。
const dns = require('dns').promises;
const net = require('net');
async function resolveAndCheck(hostname) {
const addresses = await dns.lookup(hostname, { all: true });
for (const addr of addresses) {
if (isPrivateIp(addr.address)) {
throw new Error('Blocked private IP');
}
}
return addresses[0].address;
}
function isPrivateIp(ip) {
const parts = ip.split('.').map(Number);
if (parts.length === 4) {
if (parts[0] === 10) return true;
if (parts[0] === 127) return true;
if (parts[0] === 0) return true;
if (parts[0] === 169 && parts[1] === 254) return true;
if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
if (parts[0] === 192 && parts[1] === 168) return true;
if (parts[0] === 100 && parts[1] >= 64 && parts[1] <= 127) return true;
}
return false;
}
上面的代码只处理了IPv4,实际还需要处理IPv6的::1、fc00::/7、fe80::/10等地址。更稳妥的做法是使用ipaddr.js这类库来判断地址范围。另外,DNS解析本身也可能被攻击者利用,比如返回多个A记录,其中包含内网IP,所以必须检查所有解析结果,而不是只检查第一个。
禁止重定向跟随很多请求库默认会跟随重定向,攻击者可以利用这一点,先让服务器请求一个安全的外部URL,该URL返回302跳转到http://127.0.0.1:6379/。如果请求库自动跟随,前面的校验就形同虚设。因此,要么禁止重定向,要么在每次重定向后重新执行完整的URL和IP校验。
const axios = require('axios');
const response = await axios.get(url, {
maxRedirects: 0,
validateStatus: (status) => status >= 200 && status < 400
});
如果业务必须支持重定向,就需要手动处理,在每次重定向拿到新的Location后,重新走一遍URL解析、hostname白名单、DNS解析、IP私网检查的流程。不要信任任何一跳重定向。
限制请求协议和端口除了http和https,攻击者还可能尝试file://、gopher://、ftp://等协议。Node.js的http和https模块不会直接处理这些协议,但一些请求库或自定义逻辑可能会。所以协议必须严格限制为http和https。端口也要限制,比如只允许80和443,或者只允许业务需要的特定端口。攻击者常用非标准端口探测内网服务,比如Redis的6379、MongoDB的27017、Elasticsearch的9200、Docker的2375等。
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Only http and https are allowed');
}
if (parsed.port && !['80', '443', '8080'].includes(parsed.port)) {
throw new Error('Port not allowed');
}
注意,如果URL中没有显式端口,parsed.port为空字符串,但实际请求会走默认端口,所以判断时要同时考虑默认端口。
网络层隔离与出口控制代码层面的校验是最后一道防线,更根本的防护是在网络架构上做隔离。把发起外部请求的服务放在一个独立的网络区域,通过防火墙或安全组限制该区域只能访问公网,不能访问内网网段和云元数据地址。这样即使代码被绕过,攻击者也无法访问内网资源。对于云环境,可以禁用实例的元数据服务,或者使用IMDSv2并要求令牌,增加攻击难度。
请求超时与响应大小限制SSRF攻击有时不是为了读取数据,而是为了造成拒绝服务。攻击者传入一个响应极慢或者响应体极大的URL,耗尽服务器资源。因此必须设置请求超时和响应大小限制。
const response = await axios.get(url, {
timeout: 5000,
maxContentLength: 1024 * 1024,
maxRedirects: 0
});
对于流式响应,要在数据累积超过阈值时主动中断请求,避免内存溢出。
使用代理和请求签名如果业务需要访问内部API,不要直接让用户输入的URL指向内部服务。可以在内部API前面加一层代理,代理只接受带有特定签名的请求,外部用户无法直接构造。对于Webhook回调,可以在回调URL中加入随机token,服务端验证token后才执行请求。这样即使攻击者知道内部地址,也无法直接触发。
监控与日志记录所有服务端发起的请求,包括目标URL、解析后的IP、请求时间、响应状态。当出现大量请求内网地址、非常用端口、连续重定向等异常模式时,及时告警。日志中不要记录敏感信息,但请求目标地址和解析结果对排查SSRF攻击至关重要。
常见绕过手法与防御对照攻击者用http://2130706433/表示127.0.0.1,用http://0x7f000001/表示127.0.0.1,用http://0177.0.0.1/表示八进制,用http://[::1]/表示IPv6回环。这些在URL解析后得到的hostname可能是数字、十六进制或IPv6形式,必须用标准库解析后再判断,不能靠字符串匹配。攻击者用http://example.com@127.0.0.1/,解析后hostname是127.0.0.1,必须基于解析结果判断。攻击者用http://127.0.0.1.nip.io/这类DNS服务,把域名解析到内网IP,必须做DNS解析后的IP检查。攻击者用短链接服务跳转,必须禁止或严格校验重定向。攻击者用DNS重绑定,第一次解析返回公网IP,第二次解析返回内网IP,必须在每次请求前重新解析并检查,或者使用固定IP的请求方式。
代码审计要点在Node.js项目中排查SSRF风险,重点关注所有接受URL参数的地方,包括req.query、req.body、req.params、请求头中的地址字段。搜索http.get、https.get、axios、fetch、request、got、superagent、node-fetch等请求调用。检查这些调用是否直接使用了用户输入,是否有统一的URL校验函数,是否禁用了重定向,是否检查了DNS解析结果。对于使用puppeteer、playwright等无头浏览器的场景,也要注意页面内跳转和资源加载可能触发SSRF。
实际防御代码示例下面是一个相对完整的Node.js SSRF防御函数,结合了URL解析、协议端口限制、DNS解析和IP检查、禁止重定向。
const { URL } = require('url');
const dns = require('dns').promises;
const axios = require('axios');
const ipaddr = require('ipaddr.js');
async function safeRequest(inputUrl) {
const parsed = new URL(inputUrl);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Protocol not allowed');
}
const port = parsed.port || (parsed.protocol === 'https:' ? '443' : '80');
if (!['80', '443'].includes(port)) {
throw new Error('Port not allowed');
}
const addresses = await dns.lookup(parsed.hostname, { all: true });
for (const addr of addresses) {
const ip = ipaddr.parse(addr.address);
const range = ip.range();
if (range !== 'unicast') {
throw new Error('IP not allowed');
}
}
const response = await axios.get(parsed.href, {
maxRedirects: 0,
timeout: 5000,
maxContentLength: 1024 * 1024,
validateStatus: (status) => status >= 200 && status < 400
});
return response.data;
}
这个函数中,ipaddr.js的range()方法会返回'unicast'、'private'、'loopback'、'linkLocal'、'reserved'等值,只有'unicast'才允许放行。需要注意的是,某些云厂商的元数据地址169.254.169.254属于linkLocal,会被拦截。如果业务确实需要访问某些特殊地址,要单独加白名单并做严格限制。
SSRF的防御不能依赖单一手段,必须多层配合。URL解析校验、DNS解析后IP检查、禁止重定向、协议端口限制、网络隔离、请求超时和大小限制、监控告警,每一层都在缩小攻击面。Node.js后端开发者应该把这些检查封装成统一的请求工具函数,所有需要服务端发起请求的地方都走这个函数,而不是各自为政地直接调用请求库。
