Danrafaelbr/safe-url-guard

GitHub: Danrafaelbr/safe-url-guard

一个零依赖的 Node.js SSRF 防护库,通过将地址解析为规范字节格式来拦截用户 URL 中的内部或保留地址,防止字符串比较绕过。

Stars: 0 | Forks: 0

# safe-url-guard 为 Node.js 中用户提供的 URL 提供 SSRF 防护。零依赖。 如果你的服务器会去请求用户配置的 URL —— 无论是 webhook 目标、导入头像、RSS 源,还是“检查我的网站”按钮 —— 该用户都可以将其指向你的内部网络。`169.254.169.254` 会泄露云凭据。`127.0.0.1:6379` 就是你的 Redis。这个工具会在你发起请求之前对目标地址进行校验。 ``` npm install safe-url-guard ``` ``` const { assertSafeUrl } = require('safe-url-guard'); const { url, addresses } = await assertSafeUrl(userSuppliedUrl); // throws UnsafeUrlError if the destination is internal, reserved or unresolvable const res = await fetch(url); ``` ## 大多数实现都会出错的地方 最显而易见的防护是把地址作为**文本**来比较: ``` if (ip === '::1' || ip.startsWith('fe8')) return BLOCK; // bypassable ``` IPv6 的一个地址可以有多种文本表示形式,因此字符串检查只能阻挡你想到的那种写法。下表中的每一行都是真实的内部地址,但基于字符串比较的防护会让它们直接通行无阻: | 输入 | 实际解析为 | 为什么字符串检查会漏掉它 | |---|---|---| | `0:0:0:0:0:0:0:1` | `::1` loopback | 与 `"::1"` 字符串不相等 | | `::ffff:7f00:1` | `127.0.0.1` | IPv4 映射地址,以十六进制书写 | | `::ffff:a9fe:a9fe` | `169.254.169.254` | **云元数据端点**,以十六进制书写 | | `::127.0.0.1` | loopback | IPv4 *兼容*,而非 IPv4 *映射* | | `2002:a9fe:a9fe::` | `169.254.169.254` | 封装该地址的 6to4 隧道 | | `64:ff9b::a9fe:a9fe` | `169.254.169.254` | 封装该地址的 NAT64 | 本库会将每个地址解析为其规范的 16 字节格式并进行数值匹配,因此同一地址的所有写法都会得到一致的结果。这些情况已作为回归测试固化在 `test.js` 中。 ## API ### `assertSafeUrl(rawUrl, options?)` 返回 `{ url: URL, addresses: string[] }`。抛出带有 `code` 的 `UnsafeUrlError`: | code | 含义 | |---|---| | `INVALID_URL` | 无法解析为 URL | | `PROTOCOL_NOT_ALLOWED` | 不在 `options.protocols` 中(默认为 `http:`、`https:`) | | `INTERNAL_HOSTNAME` | `localhost`、`*.local`、`*.internal`、`*.localhost`、`*.home.arpa` | | `PRIVATE_IP` | 字面量或解析出的地址位于非公开范围内 | | `DNS_FAILED` | 主机名无法解析 | 选项:`protocols`(允许的协议数组)、`lookup`(注入解析器;供测试使用,也可用于缓存)。 如果一个主机名返回了多条记录,只要存在**任何**一条内部记录,就会拒绝整个目标地址 —— 不能让主机是否可访问取决于解析器顺序的随机性。 ### `isPrivateIp(ip)` 对于任何非公开地址(IPv4 或 IPv6,以及任何文本形式),均返回 `true`。 ## DNS 重绑定 —— 请务必阅读 `assertSafeUrl` 会解析主机名,随后你的 HTTP 客户端在建立连接时会**再次**解析它。控制权威 DNS 的攻击者可以在第一次查询时返回一个公开地址,而在第二次查询时返回 `127.0.0.1`。于是检查通过了,但请求却依然落入了你的内部网络。 这就是为什么 `assertSafeUrl` 会返回 `addresses`:将它们锁定在连接中,确保请求发送到你验证过的目的地。 ``` const { url, addresses } = await assertSafeUrl(input); const agent = new http.Agent({ lookup: (host, opts, cb) => cb(null, addresses[0], net.isIPv6(addresses[0]) ? 6 : 4), }); await fetch(url, { agent }); ``` 另外建议搭配使用的措施:阻止重定向(或者重新验证每一次跳转 —— 如果检查只针对第一个 URL 运行,那么一个重定向到 `http://169.254.169.254/` 的 `302` 就会绕过检查),以及设置响应大小上限。 没有哪个 URL 验证器能够单枪匹马解决重绑定问题。任何在不锁定 IP 的情况下声称能做到这一点的说法都是错误的。 ## 被视为非公开的范围 **IPv4** — `0/8`、`10/8`、`100.64/10`、`127/8`、`169.254/16`、`172.16/12`、 `192.0.0/24`、`192.0.2/24`、`192.88.99/24`、`192.168/16`、`198.18/15`、 `198.51.100/24`、`203.0.113/24`、`224/4`、`240/4`。 **IPv6** — `::/96`(未指定地址、loopback、IPv4 兼容)、`::ffff:0:0/96` (IPv4 映射,递归进入 IPv4 规则)、`64:ff9b::/96`(NAT64)、 `100::/64`、`2001::/32`(Teredo)、`2001:db8::/32`、`2002::/16`(6to4,递归)、 `fc00::/7`、`fe80::/10`、`ff00::/8`。 ## 环境要求 Node 18+。无依赖。运行 `node --test` 执行测试套件。 ## 许可证 MIT
Português Proteção contra SSRF para URLs que o usuário configura em Node.js. Sem dependências. Se o seu servidor busca uma URL que o usuário definiu — destino de webhook, importar avatar, fonte de RSS —, esse usuário pode apontar para a sua rede interna. `169.254.169.254` entrega credencial de nuvem; `127.0.0.1:6379` é o seu Redis. O erro comum é comparar o endereço como **texto**. IPv6 tem várias grafias para o mesmo endereço, então uma checagem por string só bloqueia a grafia em que você pensou — `::1` é bloqueado, `0:0:0:0:0:0:0:1` passa, e os dois são o mesmo lugar. A tabela acima lista seis endereços internos reais que escapam de checagem textual. Aqui todo endereço é convertido para os 16 bytes canônicos e comparado numericamente. Os casos estão fixados como teste de regressão. **Importante:** leia a seção de DNS rebinding. Nenhum validador de URL resolve isso sozinho — é preciso fixar o IP validado na conexão, e `assertSafeUrl` devolve `addresses` justamente para isso.
标签:GNU通用公共许可证, IP解析, MITM代理, Node.js, SSRF防护, Web安全, YAML, 安全库, 数据可视化, 自定义脚本, 蓝队分析