Chinozilla/ssrf-safe-fetch

GitHub: Chinozilla/ssrf-safe-fetch

为 Node.js 的 fetch 提供零依赖的 SSRF 防护,通过私有 IP 阻断和逐跳重定向校验防止服务端被诱导访问内部网络。

Stars: 0 | Forks: 0

# ssrf-safe-fetch [![CI](https://static.pigsec.cn/wp-content/uploads/repos/2026/06/b9cf7408c2180117.svg)](https://github.com/Chinozilla/ssrf-safe-fetch/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/ssrf-safe-fetch.svg)](https://www.npmjs.com/package/ssrf-safe-fetch) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) 为 Node.js 中的服务端请求提供 SSRF 防护:URL 校验、私有/保留 IP 阻断(IPv4 和 IPv6),以及一个在每次重定向跳转时重新进行校验的 `fetch` 包装器。 零运行时依赖。支持 ESM 和 CommonJS。TypeScript 优先。 提取自生产环境的 Web 应用,用于保护可用性爬虫及其他处理用户提供的 URL 的出站请求。在一次安全审计标记了未经验证的 SSRF 端点后,该校验逻辑便部署于此,并在生产环境中经过了实战检验。 ## 什么是 SSRF? 服务端请求伪造(SSRF)发生在攻击者能够使*你的服务器*向其控制或选择的 URL 发送请求时。典型的入口点包括: - **Webhook URL** — “当发生 X 时通知此 URL”表单。 - **用户提供的链接** — 链接预览、工单/产品 URL、头像 URL、RSS 订阅源 URL、从 URL 导入功能。 - **爬虫和可用性检查器** — 任何定期获取第三方页面的操作。 为何重要:从你的基础设施内部来看,`http://169.254.169.254/`(云元数据,通常持有凭证)、`http://localhost:6379/`(Redis)或 `http://10.0.0.5/admin` 等地址即使从互联网上不可见,也是可以访问的。简单的 `fetch(userUrl)` 会将你的服务器变成访问该内部网络的代理。重定向会使简单的黑名单失效:攻击者提交一个看似无害的公共 URL,然后其通过 302 重定向到内部主机。 本库弥补了这两个漏洞: 1. **获取前进行校验** — 协议必须为 http(s);主机(字面量 IP 或每个 DNS 解析出的地址)必须位于私有/保留范围之外。 2. **校验每一次重定向跳转** — `safeFetch` 会禁用自动跟随重定向,并在请求每个 `Location` 目标之前,对其重新运行完整的校验逻辑。 ## 安装说明 ``` npm install ssrf-safe-fetch ``` 要求 Node.js 18.17 或更高版本(使用全局 `fetch`、`node:net` 以及 `node:dns/promises`)。 ## 快速入门 ``` import { safeFetch, SsrfError } from "ssrf-safe-fetch"; try { const res = await safeFetch(userSuppliedUrl, { headers: { Accept: "text/html" } }); const html = await res.text(); } catch (err) { if (err instanceof SsrfError) { // Blocked: private/reserved target, bad protocol, DNS failure, or too many redirects. } throw err; } ``` 或者只校验而不发起请求(例如,当存储 Webhook URL 时): ``` import { assertPublicUrl, isSafeHttpUrl } from "ssrf-safe-fetch"; if (!isSafeHttpUrl(input)) reject(); // sync, cheap: protocol/shape check only await assertPublicUrl(input); // async: full check incl. DNS resolution ``` ## API ### `safeFetch(url, init?, options?): Promise` SSRF 安全的 `fetch` 直接替代品。使用 `assertPublicUrl` 校验 URL,以 `redirect: "manual"` 执行请求,并在跟随每一次重定向跳转前重新校验。返回最终的 `Response`;当任何跳转被阻断或超出重定向限制时,抛出 `SsrfError`。 - `init` — 标准的 `RequestInit`,原样传递,除了 `redirect`(始终强制为 `"manual"`)和 `signal`(在 Node >= 20.3 上通过 `AbortSignal.any` 与单次跳转超时结合,否则直接替换为单次跳转超时)。 - `options.maxRedirects` — 要跟随的最大重定向次数。默认 `4`。 - `options.timeoutMs` — 单次跳转超时时间(毫秒)。默认 `5000`。 - `options.lookup` — 自定义 DNS 解析器(参见 `assertPublicUrl`)。 - `options.fetchImpl` — 自定义 `fetch` 实现(例如带有代理调度器的 undici 客户端,或测试中的 mock)。 行为说明: - `init` 在每次跳转时原样重新发送,包括请求方法和 body。 有意未实现浏览器风格的 303 “切换至 GET” 语义 — 避免对非幂等请求跟随重定向。 - 没有 `Location` 标头的 3xx 响应将原样返回。 ### `assertPublicUrl(url, options?): Promise` 校验单个 URL 并返回解析后的 `URL`,否则抛出 `SsrfError`: 1. 协议必须为 `http:` 或 `https:`(通过 `isSafeHttpUrl` 判断)。 2. 如果主机是字面量 IP(包括带中括号的 IPv6 字面量以及 WHATWG URL 解析器规范化的十进制/八进制/十六进制 IPv4 编码,例如 `http://2130706433/` 或 `http://0x7f000001/`),则直接将其与阻断范围进行比对检查 — 不涉及 DNS。 3. 否则解析主机名(`dns.lookup`,带 `all: true`),并且返回的**每一个**地址都必须是公共的。公共地址中只要包含一个私有 A/AAAA 记录,就会拒绝该 URL。 `options.lookup` 允许你注入自定义解析器,其契约与 `node:dns/promises` 的 `lookup(host, { all: true })` 相同 — 适用于测试、缓存解析器或 DNS pinning。 在初始 URL *以及* 每次重定向跳转时调用此方法 — 或者直接使用 `safeFetch`,它正是这么做的。 ### `isPrivateIp(ip): boolean` 作用于 IP 地址字符串的纯断言函数。对于私有、loopback、link-local 以及其他保留地址,返回 `true`。**故障封闭:** 任何在语法上不是有效 IPv4/IPv6 地址的字符串也返回 `true`。 被阻断的 IPv4 范围: | 范围 | 原因 | |---|---| | `0.0.0.0/8` | “本”网络 | | `10.0.0.0/8` | RFC 1918 私有 | | `100.64.0.0/10` | 运营商级 NAT (RFC 6598) | | `127.0.0.0/8` | loopback | | `169.254.0.0/16` | link-local,包含云元数据 `169.254.169.254` | | `172.16.0.0/12` | RFC 1918 私有 | | `192.168.0.0/16` | RFC 1918 私有 | | `224.0.0.0/3` | 多播、保留、广播 (224-255) | 被阻断的 IPv6 范围: | 范围 | 原因 | |---|---| | `::` / `::1` | 未指定 / loopback | | `fe80::/10` | link-local | | `fc00::/7` | 唯一本地地址 | | `ff00::/8` | 多播 | | `::ffff:a.b.c.d` | IPv4 映射(点分*和*十六进制形式)— 内嵌的 IPv4 会被检查 | | `::a.b.c.d` | 已弃用的 IPv4 兼容 — 内嵌的 IPv4 会被检查 | | `64:ff9b::/96` | NAT64 知名前缀 — 内嵌的 IPv4 会被检查 | ### `isSafeHttpUrl(url): url is string` 同步类型保护:仅当字符串解析为绝对的 `http:`/`https:` URL 时才返回 `true`。拒绝 `javascript:`、`data:`、`file:`、`ftp:`、协议相对 URL 以及非字符串输入。不依赖 Node 内置模块 — 可在任何 JavaScript 运行时中使用(也可用于净化 `href` 值)。 ### `SsrfError` 由 `assertPublicUrl` 和 `safeFetch` 为每个被阻断的请求抛出的错误子类(`name === "SsrfError"`),以便调用者可以区分策略拒绝和网络错误。 ## 重定向跳转校验的工作原理 ``` safeFetch(url) └─ hop 0: assertPublicUrl(url) -> blocked? throw SsrfError fetch(url, redirect:manual) 3xx + Location? └─ hop 1: assertPublicUrl(location) -> blocked? throw SsrfError (target never fetched) fetch(location, ...) └─ ... up to maxRedirects, else SsrfError("Too many redirects") ``` 因为在发起任何请求之前,每个 `Location` 目标都会经过*完整*的校验(协议、字面量 IP 检查、每条记录的 DNS 解析),所以经典的绕过手法 — 公共 URL 重定向到 `http://169.254.169.254/latest/meta-data/` — 会在跳转边界处被拦截。 相对 `Location` 标头将首先针对当前 URL 进行解析。 ## 安全模型与局限性 需要坦诚说明此类库能做什么和不能做什么: - **DNS rebinding (TOCTOU) 未被完全阻止。** `assertPublicUrl` 会自行解析主机名,但随后的 `fetch` 会执行其*自己*的 DNS 解析。具有极低 TTL 的恶意权威 DNS 服务器可以在校验查询时返回公共地址,并在 fetch 时返回私有地址。要完全阻止这种情况,需要将连接固定到已校验的地址(自定义拨号器/agent),而全局 `fetch` 并未公开此功能。如果这属于你的威胁模型,请通过 egress proxy 终止出站流量,或者使用带有固定地址的 undici 调度器的 `options.fetchImpl`。 - **不是白名单。** 这是一个已知私有/保留范围的黑名单。如果你的内部服务位于*公共* IP(或公共负载均衡器后面)上,本库不会阻止对它们的请求 — 请在此基础上添加你自己的白名单/黑名单。 - **某些特殊用途的 IPv4 范围未被阻断**,因为它们看起来是可路由的文档/基准测试空间,而不是内部基础设施:`192.0.0.0/24`、`192.0.2.0/24`、`198.51.100.0/24`、`203.0.113.0/24` (TEST-NET)、`198.18.0.0/15` (基准测试)。同样未被阻断的还有已弃用的 IPv6 站点本地地址 `fec0::/10`。如果你的环境需要,欢迎提交 PR。 - **混淆的 IPv4 形式仅在 WHATWG URL 解析器规范化的范围内被覆盖。** Node 的 `URL` 会在校验前将十进制 (`2130706433`)、八进制 (`0177.0.0.1`)、十六进制 (`0x7f000001`) 和简写 (`127.1`) 主机规范化为点分 IPv4,因此这些形式会被阻断。通过 DNS 解析的主机名把戏(例如指向 127.0.0.1 的 `localtest.me` 风格的通配符域)则会改为被解析后的地址检查所阻断。 - **响应未被净化。** 本库仅决定是否可以发送请求;你如何处理响应 body 取决于你自己。 - **不提供针对请求走私、公共 IP 上的开放端口或针对合法公共目标的应用层攻击的保护。** ## 起源 此代码提取自生产环境的 Web 应用,在那里它保护定期的可用性爬虫及其他处理用户提交 URL 的服务端请求。对该应用的白盒安全审计发现了一个未经验证的 SSRF 向量(攻击者控制的 URL 被传递给跟随重定向的 HTTP 客户端);其修复方案 — 带有私有范围阻断的单次跳转校验 — 成为了本库。在提取过程中加固了 IPv6 处理(完整的 `fe80::/10` 覆盖、十六进制形式的 IPv4 映射地址、NAT64 前缀)。 ## 许可证 [MIT](./LICENSE) — 版权所有 (c) 2026 Anhthien Nguyen
标签:CISA项目, GNU通用公共许可证, MITM代理, Node.js, SSRF防御, TypeScript, 安全插件, 安全防护, 文档安全, 网络请求库, 自动化攻击