Chinozilla/ssrf-safe-fetch
GitHub: Chinozilla/ssrf-safe-fetch
为 Node.js 的 fetch 提供零依赖的 SSRF 防护,通过私有 IP 阻断和逐跳重定向校验防止服务端被诱导访问内部网络。
Stars: 0 | Forks: 0
# ssrf-safe-fetch
[](https://github.com/Chinozilla/ssrf-safe-fetch/actions/workflows/ci.yml)
[](https://www.npmjs.com/package/ssrf-safe-fetch)
[](./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, 安全插件, 安全防护, 文档安全, 网络请求库, 自动化攻击