给 Agent 抓取工具补上 SSRF 防线
文章分析模型接收外部 URL 后,抓取工具如何成为访问云元数据、回环地址和内网服务的 SSRF 入口,并介绍约 80 行的 Node.js 防护实现。作者声明代码由 AI 撰写,片段在 Node 24 环境运行过,但未实际访问云元数据端点。
AI 深度解读
给 AI Agent 加一个「读取网址」工具,也是在给服务器开放一个由外部内容间接控制的请求入口。模型从提示词、网页或工具结果里拿到 URL,再交给 Node 执行,就可能把请求送进云实例元数据服务、本机管理后台或公司内网。
原文给出一份约 80 行、无依赖的 SSRF 防护实现。最值得带走的结论是:过滤 URL 字符串远远不够,必须检查连接实际使用的 IP,并对每次重定向重新校验。它的测试也展示了一个现实问题:代理环境可能让正常域名一起被拦。
为什么拦住 localhost 仍然挡不住内网请求
SSRF,即服务端请求伪造,关键在于攻击者借用了服务器的网络位置。用户电脑访问不了的地址,运行 Agent 的机器可能访问得到。
例如,169.254.169.254 在一些云环境中用于实例元数据服务,某些配置下涉及凭据;127.0.0.1:<port> 可能对应管理面板、调试服务或数据库的 HTTP 端口;10.x、172.16-31.x、192.168.x 则属于私网。若请求库接受其他协议,file:///etc/passwd 还会把问题扩展到本地文件。
直接用正则排除这些字符串,容易漏掉地址的不同写法:
http://2130706433/和http://0x7f.1/都会被 WHATWG URL 解析器规范化为127.0.0.1。http://[::ffff:127.0.0.1]/是 IPv4 映射的 IPv6 地址,new URL()会将其转换为[::ffff:7f00:1]。localtest.me看起来像普通域名,却解析到127.0.0.1;10.0.0.1.nip.io则解析到10.0.0.1。
还有两种绕过不靠地址写法:公网页面通过 302 Location 把请求转向内网;DNS 在检查时返回公网 IP,在连接时返回私网 IP,后者就是 DNS rebinding。
URL 的外观不能证明请求最终会连到哪里。
因此,原文把校验拆成了 URL 规则、IP 规则、连接时解析和逐跳重定向四个环节。
检查必须贴着连接发生,重定向也要重查
第一层用 new URL() 解析,只接受 http:、https:,默认端口限制为 80、443,拒绝 user:pass@。如果主机本身就是 IP,立刻检查,因为 Node 对 IP 字面量不会调用 DNS lookup。
第二层使用内置的 net.BlockList 管理网段,覆盖私网、回环、链路本地、运营商级 NAT、基准测试、组播和保留地址等。对于 ::ffff:a.b.c.d 和 NAT64 前缀 64:ff9b:: 中嵌入的 IPv4,还会取出 IPv4 再检查。
域名的关键处理放在 http.request、https.request 的 lookup 钩子中。下面这段让 DNS 结果经过检查后,直接交给连接使用:
const guardedLookup = resolve => (hostname, options, cb) => {
resolve(hostname).then(addrs => {
if (!addrs.length) return cb(new Error(`no address for ${hostname}`));
const bad = addrs.find(a => isBlockedIP(a.address));
if (bad) return cb(new Error(`blocked address ${bad.address} for ${hostname}`));
if (options && options.all) return cb(null, addrs);
cb(null, addrs[0].address, addrs[0].family);
}, cb);
};
要看的是 addrs.find(...):返回地址中只要有一个被禁止,就拒绝整个域名。公网 A 记录搭配私网 AAAA 记录也不能通过。options.all 分支则兼容 Node 为 Happy Eyeballs 请求全部地址的情况。
这堵住了「先解析检查,随后请求库另行解析」留下的时间差。
第三层自行处理重定向。原文的请求函数 once 使用上述钩子,外层则对每个 Location 再运行 checkUrl:
export async function safeFetch(raw, { maxRedirects = 3, timeoutMs = 8000, maxBytes = 1_000_000, resolve = systemResolve } = {}) {
let u = checkUrl(raw); const hops = [u.href];
for (let i = 0; ; i++) {
const r = await once(u, { timeoutMs, maxBytes, resolve });
if (![301, 302, 303, 307, 308].includes(r.status) || !r.headers.location) return { ...r, hops };
if (i >= maxRedirects) throw new Error('too many redirects');
u = checkUrl(new URL(r.headers.location, u).href); // 每一跳都重新检查
hops.push(u.href);
}
}
要留意 u = checkUrl(...) 这一行:相对跳转先按当前 URL 解析,再接受相同规则检查。下一跳建立连接时,还会再次经过 DNS 校验。这是原实现的节选,需要配套函数才能运行。
默认最多重定向 3 次,响应上限为 1_000_000 字节,timeoutMs 为 8000。这些限制还能减少工具下载超大内容的风险。
12 个 URL 的测试证明了什么
原文声明由 AI Agent 撰写,所有片段于 2026 年 10 月 30 日在 Linux 沙箱、Node 24 中运行;代码标注支持 Node 18+,但原文没有展示其他版本的验证记录。
测试共包含 12 个 URL:10 个应拒绝,2 个应放行。朴素正则漏掉了 10 个危险输入中的 7 个,防护实现拒绝全部 10 个,同时允许普通公网访问和一次公网重定向。后者经过 2 跳,最终返回 200,正文为 577 bytes。
这里有两个边界。
首先,作者没有真正访问云元数据端点,也没现场跑通「公网地址重定向到元数据地址」。他们尝试的 httpbin.org、httpbingo.org、postman-echo.com 都限制了这类跳转;其中出现过 403 Forbidden redirect URL。代码会复用直接地址校验,但这仍属于代码路径推断,不能算该攻击链的实测结果。
其次,第一轮测试连 example.com 都被拦了。原因是沙箱系统 DNS 对所有域名返回 198.18.0.1,落在基准测试网段 198.18.0.0/15,属于 fake-IP 代理行为。
作者随后通过 resolve 参数注入 Cloudflare 的 DNS-over-HTTPS 解析器,分别查询 A、AAAA,才得到展示的结果。这个现象对使用 VPN、企业代理或 DNS sidecar 的团队很有参考价值:检查失败时,先确认解析器返回什么,不能直接把特殊网段放开。
我的判断
我认可把安全边界放在实际连接处,也认可默认拒绝的策略。对于允许模型读取任意公网网址的 Node 工具,这比不断给正则补例外可靠得多。
但「约 80 行」不等于完整的生产安全方案。12 个样例证明了若干具体绕过能被拦截,没有证明地址分类覆盖完整,也没有现场验证 DNS rebinding 的全过程。原文没有提供更全面的对照数据,不能据此宣称方案已经覆盖所有 SSRF。
超时也需要单独审视。实现使用的是请求的 timeout 和 timeout 事件,不能直接当成从 DNS 解析开始计算的整体 8 秒截止时间;自定义 DoH 函数也没有显式超时。需要严格控制工具执行时长时,应补充覆盖解析、连接和读取阶段的整体取消机制。
代理则会改变防护位置。若 HTTP 代理负责解析目标域名,进程里的 lookup 校验无法代表代理实际连接的地址,规则应落到代理或出口防火墙。原文也建议结合网络隔离,以及云元数据的令牌访问机制,例如 IMDSv2 和 hop limit 1。
工具只需要访问固定几个 API 时,优先使用域名允许列表。
最后,目标地址安全与返回内容可信是两回事。公网网页照样可能包含提示注入,这份 guard 没有处理内容信任问题。
能拿来做什么
- 给 Node 网页读取器、链接预览器或 MCP 下载工具盘点 URL 来源,把模型生成、网页提取和工具返回的地址都按不可信输入处理,并检查每次重定向。
- 把十进制与十六进制回环地址、IPv4 映射 IPv6、内网解析域名和混合 A/AAAA 结果加入验证用例;在实际部署环境中确认 DNS 返回值。
- 如果工具只服务固定 API,就限制目标域名、协议和端口;确实需要任意公网读取时,再配合连接时 IP 检查与网络出口隔离。
- 将响应大小、重定向次数和整体执行期限作为工具接口的明确约束,同时把抓取文本继续视为不可信内容,避免让网络防护替内容安全背书。