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后端开发者应该把这些检查封装成统一的请求工具函数,所有需要服务端发起请求的地方都走这个函数,而不是各自为政地直接调用请求库。