跳到主要内容
CodeAuditAgent
全部文章

面向 Webhook、链接预览和 URL 抓取器的 SSRF 防御

SSRF 如何把 webhook、链接预览和导入器变成通往云元数据和内部服务的通道,以及真正有效的防御,附 Node.js 示例。

· 阅读约 7 分钟 · Lina Source LLC

任何接收用户提供的 URL 并由你的服务器去抓取的功能,都是潜在的服务端请求伪造(SSRF,CWE-918)。Webhook、链接预览、按 URL 设置头像、RSS 与日历导入、PDF 渲染器以及“从 URL 导入”按钮,形态都一样:你的服务器向别人选定的目标发起请求。

问题在于你的服务器所处的位置。它能访问攻击者访问不到的东西:云元数据服务、内部管理面板、私有网络上没有密码的数据库,以及 localhost 上的服务。SSRF 把你的服务器变成攻击者通往那个网络的代理。它在小团队里尤其容易被引入,因为导致它的功能看上去人畜无害:设置页里的一个 URL 输入框、聊天里的一张预览卡片、一个下载头像的辅助函数。这些都不像网络安全代码,所以很少按那样的标准被评审。

攻击者的目标

  • 云元数据端点,最著名的是 AWS、GCP 和 Azure 上的 169.254.169.254,它可能返回实例信息,在某些配置下还会返回该机器角色的临时凭据。
  • 绑定在 localhost 上的服务,比如管理界面、调试服务器和指标端点,它们都假定只有本地调用方。
  • 私有地址段(10.0.0.0/8、172.16.0.0/12、192.168.0.0/16)上的内部服务,它们没有认证,因为原本就不打算从外部访问。
  • 端口扫描和服务发现,利用响应时间或错误信息来摸清内部网络。
  • 非 HTTP 协议,如果抓取库支持 file:、gopher: 或 ftp: 这类协议方案。

即便响应不会返回给攻击者,盲 SSRF 仍然可以对内部端点触发改变状态的请求。Webhook 往往是盲的,但这并不意味着它们安全。许多内部服务正是因为假定外部无人可达,才接受用简单的 GET 请求改变状态,比如清除缓存或藏在某个 URL 后面的管理操作。

为什么简单的校验会失效

第一反应是解析 URL,拒绝 localhost 这样的主机名或以 10. 和 192.168. 开头的字符串。这会因为好几个原因失效。

  • 主机名会解析成 IP。攻击者注册一个 A 记录指向 127.0.0.1 或 169.254.169.254 的域名,主机名校验便看不出任何问题。
  • IP 地址有很多种写法:十进制(2130706433)、十六进制、127.1 这样的简写,以及 ::ffff:127.0.0.1 这类 IPv4 映射的 IPv6 地址。字符串匹配会漏掉它们。
  • DNS 重绑定:你校验时该域名解析到一个公网 IP,片刻之后 HTTP 客户端连接时又解析到一个私有 IP。校验和连接是两次独立的查询。
  • 重定向:你校验过的 URL 返回一个指向 http://169.254.169.254/ 的 302,HTTP 客户端会不声不响地跟过去。

共同点在于,校验发生在套接字实际连接的地址之外的东西上。修复办法是在连接时校验解析出的 IP,对每一次连接都如此,包括重定向之后。

防御手段,由强到弱

能用允许列表就用允许列表

如果该功能只需要与一组已知主机通信,比如与少数几家服务商的集成,那就把这些主机名列入允许列表,拒绝其他一切。这是最强的控制手段,也最容易推理。请对解析出的主机名做精确比较,不要用 startsWith 或 endsWith 校验,它们会接受 api.example.com.attacker.net 或 evilexample.com 这类相似主机。下面的内容都是为那些必须接受任意公网 URL 的功能准备的。

限制协议方案和端口

只接受 https:(万不得已才接受 http:)。拒绝 URL 中的凭据,并把端口限制为 443 和 80,除非确有需要。请用标准的 URL 解析器解析,绝不要用正则;WHATWG 解析器还会规范化各种古怪的 IP 写法,这对后续校验有帮助。

先解析,再在连接时校验 IP

阻断回环地址、私有地址、链路本地地址、运营商级 NAT、未指定地址,以及 IPv6 的唯一本地地址和链路本地地址段。关键在于,要把校验放在 HTTP 客户端所使用的 DNS 查询内部,这样被校验的地址就是被连接的地址。这样就堵上了 DNS 重绑定的缺口。Node 的 http 和 https 模块正好接受一个自定义的 lookup 函数。

下面的阻断列表覆盖了对多数部署而言重要的地址段。请把属于你自己基础设施的公网地址段也加进去,因为拥有公网 IP 的负载均衡器或内部 API 仍然可能信任来自你网络内部的请求。只要主机名解析出的任意一个地址被阻断,就拒绝该主机名,而不只是看第一个,因为客户端可能以任意顺序尝试它们。

// safe-lookup.js
import dns from "node:dns";
import net from "node:net";

const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4 映射
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");

export function isBlockedIp(address, family) {
  return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}

// 在套接字连接之前校验每一个解析出的地址
export function safeLookup(hostname, options, callback) {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err);
    const denied =
      addresses.length === 0 ||
      addresses.some((a) => isBlockedIp(a.address, a.family));
    if (denied) {
      return callback(new Error("Destination not allowed: " + hostname));
    }
    if (options.all) return callback(null, addresses);
    callback(null, addresses[0].address, addresses[0].family);
  });
}

有一个细节容易被忽略:当 URL 中直接是 IP 字面量时,Node 会直接连接,根本不会调用 lookup。所以请求函数必须在移交之前自己检查 IP 字面量。

import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";

export function postWebhook(rawUrl, payload) {
  const url = new URL(rawUrl);
  if (url.protocol !== "https:") throw new Error("Only https is allowed");
  if (url.port && url.port !== "443") throw new Error("Port not allowed");
  if (url.username || url.password) throw new Error("Credentials not allowed");

  const host = url.hostname.replace(/^\[|\]$/g, "");
  const family = net.isIP(host);
  if (family !== 0 && isBlockedIp(host, family)) {
    throw new Error("Destination not allowed");
  }

  return new Promise((resolve, reject) => {
    const req = https.request(
      url,
      {
        method: "POST",
        lookup: safeLookup,
        timeout: 5000,
        headers: { "content-type": "application/json" },
      },
      (res) => {
        // node:https 从不跟随重定向;3xx 被当作失败处理
        res.resume();
        resolve(res.statusCode);
      }
    );
    req.on("timeout", () => req.destroy(new Error("Request timed out")));
    req.on("error", reject);
    req.end(JSON.stringify(payload));
  });
}

如果你在 Node 中使用基于 undici 的 fetch,同样的思路可以通过自定义 dispatcher 实现:创建一个带 connect.lookup 选项的 undici Agent,并把它作为 dispatcher 传入。原则不变:校验必须位于建立连接的地方。如果你的环境通过 HTTP 代理转发出站流量,请注意此时对目标主机的解析发生在代理上,因此代理本身必须执行同样的规则。

禁用重定向,或对每一跳重新校验

对 webhook,完全不要跟随重定向;会重定向的接收端是配置有误,明确地失败能促使客户去修好他们的端点。对链接预览和导入器这类重定向属于正常的场景,请手动跟随并设置一个较小的上限,同时让每一跳都经过同样的协议方案、端口和 IP 校验。使用 fetch 时,设置 redirect: "manual" 并自行处理 Location 响应头。

限制一次成功请求能做的事

  • 设置连接超时和总超时,并限制响应大小,这样抓取器就不能被用来长时间占住连接或拉取大文件。
  • 不要把原始响应或详细的错误信息返回给用户。对链接预览,只返回提取出的标题、描述和图片 URL。
  • 剥离你自己的认证请求头和 Cookie;抓取器绝不应把内部凭据发送到用户提供的主机。

通过出口代理转发

最稳妥的做法是把策略移出应用代码。让用户驱动的出站抓取经由一个专用的出口代理,或者从一个所在网段根本没有路由通往内部服务和元数据端点的隔离 worker 发出。这样 URL 校验中的漏洞就不会变成一次入侵,因为网络本身会拒绝。开源正向代理可以替你执行目的地规则,独立的 worker 还能把缓慢或恶意的响应与你的主 Web 进程隔离开。

加固元数据服务

在 AWS 上,强制使用 IMDSv2,它需要通过 PUT 请求获取会话令牌,并把跳数限制保持为 1,这样容器就无法经由宿主机访问它。GCP 和 Azure 要求元数据调用带上特定的请求头。这些措施把基于简单 GET 的 SSRF 门槛抬高了不少,但它们是第二层防护,不能替代对链路本地地址的阻断。另外,只给实例或服务角色分配应用所需的权限,让通过元数据服务窃取的凭据尽可能少地解锁东西。

测试你的防御

  • 试一试 http://127.0.0.1/、http://[::1]/、http://2130706433/、http://127.1/ 和 http://169.254.169.254/latest/meta-data/,确认每一个都被拒绝。
  • 把一个你控制的主机名指向 127.0.0.1,确认 lookup 校验会拒绝它。然后给它两条 A 记录,一条公网一条私有,确认它仍被拒绝。
  • 从一个公网主机返回指向私有地址的 302 重定向,确认它不会被跟随。
  • 试一试 file:、ftp: 和 gopher: 这类 URL,以及带凭据或非常规端口的 URL。

在代码评审中,搜索每一处由输入拼出请求 URL 的地方:fetch、axios、got、requests、http.Get、无头浏览器的 page.goto 调用,以及接受 URL 的图像处理库。CodeAuditAgent 会在公开仓库和粘贴的代码片段中追踪这条数据流,并把 SSRF 报告为 CWE-918,附带原文引用的调用点、攻击场景和补丁。