SSRF 的本质很简单:服务端替用户发了一个请求,而用户能控制这个请求去哪。看起来只是个 URL 参数,实际后果可能是内网扫描、读取云元数据、甚至拿到临时凭据。

一个典型的入口

我见过最常见的形式是图片抓取和 webhook 测试。用户填一个地址,后端下载或者回调。

POST /api/fetch?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ HTTP/1.1
Host: app.example.com
Accept: */*

云厂商的元数据服务监听在链路本地地址上,从实例内部访问不需要认证。如果这段响应被原样返回给用户,临时凭据就泄露了。现在主流云厂商都要求带额外的 header 或者用带 token 的访问方式,但这只是提高了门槛,不是根治。

绕过白名单的花样

只做字符串匹配的防御基本都会被绕过。

  • http://127.0.0.1 换成 http://2130706433 或 http://0x7f000001
  • 用 http://[::1] 或者 IPv4 映射地址
  • 域名解析到内网,先通过校验再改 DNS
  • 302 跳转,第一跳是公网,第二跳指向内网
  • URL 里的 @ 和多个斜杠,让解析器和校验器看到不同的 host

这些技巧的共同点是利用了校验和实际请求之间的差异。你的正则看到的是字符串,HTTP 客户端看到的是解析后的地址。

防御要落在出口

只在前端或者应用层做校验是不够的。我的做法分三层。

第一层是解析后校验。用标准库把 URL 解析成 host 和 port,解析 DNS 拿到真实 IP,判断是否落在私有网段、链路本地、回环、保留地址里。校验必须基于解析结果,而不是原始字符串。

import { lookup } from 'node:dns/promises'
import net from 'node:net'

const BLOCKED = new net.BlockList()
BLOCKED.addSubnet('10.0.0.0', 8)
BLOCKED.addSubnet('172.16.0.0', 12)
BLOCKED.addSubnet('192.168.0.0', 16)
BLOCKED.addSubnet('169.254.0.0', 16)
BLOCKED.addAddress('127.0.0.1')
BLOCKED.addAddress('::1')

async function assertPublic(rawUrl) {
  const url = new URL(rawUrl)
  if (!['http:', 'https:'].includes(url.protocol)) throw new Error('bad scheme')
  const { address } = await lookup(url.hostname)
  if (BLOCKED.check(address)) throw new Error('blocked')
  return url
}

第二层是出口控制。把发起外部请求的服务放在独立子网,安全组只放行 80 和 443,禁止访问元数据地址和内部管理网段。这样即使应用层被绕过,网络层还兜得住。

这里有个时间差的问题。校验和真正发起请求之间隔了一段时间,DNS 记录可能已经变了。稳妥的做法是校验时把解析出的 IP 固定下来,直接连这个 IP,Host 头单独设置,让校验的目标和连接的目标保持一致。

第三层是收敛能力。能不用用户提供的 URL 就不用。需要抓图就让用户上传,需要回调就用固定域名加签名,把自由度收窄到业务真正需要的范围。

一份检查清单

  • 是否禁止了非 http 和 https 协议
  • 是否解析 DNS 后按 IP 判断,而不是按字符串
  • 是否禁止跟随跳转,或者对每一跳重新校验
  • 是否限制了响应大小和超时
  • 是否关闭了不必要的元数据服务,或者强制使用带 token 的版本
  • 是否有出站流量的日志和告警

这份清单里最容易被忽略的是出站日志。SSRF 利用成功的标志往往不是报错,而是某个内网地址被访问了一次。有日志才能发现这件事。