Agent网页抓取工具的SSRF风险与防线
文章指出,模型选择URL的抓取工具会以服务器的网络权限发起请求,外部内容可能诱导其访问非预期地址。防护应落实在工具能力、下游权限与授权机制上,不能依赖模型自行判断。
AI 深度解读
给 AI Agent 加一个 fetch(url),交出去的不只是读网页的能力,还有服务器所在网络的访问权限。外部网页、工单或邮件一旦诱导模型继续请求某个地址,真正发起连接的是你的服务端。
作者的结论是:防住服务端请求伪造(SSRF),不能靠模型识别恶意指令,要靠工具授权、连接时校验和网络隔离。文章最有价值的地方,是公开了一次 Node.js 防护被 IP 地址绕过的测试,以及修复后仍未验证的边界。
先决定工具是否真的需要一个 URL
作者引用 OWASP Top 10 for LLM Applications(2025),把问题归入 LLM06:2025 Excessive Agency,即 Agent 获得了超出任务需要的行动能力。它有三个来源:工具功能太宽、权限太大,以及高影响操作缺少独立审批。
系统提示词可以要求模型拒绝访问内网,但请求能否发出去,最终必须由下游程序决定。这就是「完整仲裁」(complete mediation):每次访问都经过授权检查,模型的判断不能代替检查。
具体设计要先分两类。
第一类只访问少数已知目标,例如查订单、读工单、调用内部 API。此时最合适的参数通常是订单号或工单 ID,由服务端拼装请求;确实需要主机名时,再使用允许列表。让模型提交完整 URL,会额外暴露协议、路径和目标地址等自由度。
第二类需要抓取任意公开网页,目标无法提前枚举,允许列表就不能承担主要防护。作者转而强调连接时检查和网络出口限制。地址黑名单容易被绕过,复杂正则也难维护,不能把「字符串看起来正常」当成安全条件。
先收窄工具能表达的请求,再限制它实际能到达的网络。
日志、监控和限流仍然有用,但按原文引用的 OWASP 说明,它们只能帮助发现问题、限制损失,无法阻止越权请求本身。
防线要落在实际连接和进程权限上
对于开放网页抓取,作者提出五层控制。
第一,检查 socket 实际使用的解析地址。仅验证域名,或者提前查一次 DNS 再独立发请求,都无法可靠覆盖 DNS rebinding(DNS 重绑定):检查时和连接时,域名可能指向不同地址。
第二,关闭自动跟随重定向。公开页面可以返回一个指向内网的跳转地址;如果业务必须跟随,每一跳都要作为新请求重新检查。
第三,在网络层阻断不应到达的地址范围。应用检查可能遗漏输入形式,运行抓取工具的进程还需要出口规则兜底。
第四,降低云元数据服务的可达性。原文以 AWS 为例:IMDSv2 通过 PUT 获取会话 token,携带 X-Forwarded-For 的 PUT 会被拒绝,响应 hop limit 默认为 1。实例可配置为仅接受 IMDSv2,让 IMDSv1 请求失败。它增加了访问门槛,仍需要其他防线配合。
第五,给工具尽可能小的运行身份:单独进程、没有 cloud role、没有内网路由、环境变量里没有凭证。这样即使请求突破一层检查,可接触的资源也更少。
这里有个直接影响部署的细节:代码里写了哪些限制,只回答了一半问题。工具进程部署后能访问什么网络、继承什么身份,同样决定了风险。
Node.js 的坑:IP 地址不会经过 lookup
作者提供的实现只使用 Node.js 内置模块,把地址检查放进自定义 lookup。下面保留完整示例,因为漏洞和修复恰好跨越了解析与请求两个环节:
import dns from 'node:dns';
import https from 'node:https';
import net from 'node:net';
// Address ranges a public fetch tool must never reach.
const blocked = new net.BlockList();
for (const [addr, bits] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.168.0.0', 16]]) blocked.addSubnet(addr, bits, 'ipv4');
for (const [addr, bits] of [['::1', 128], ['fc00::', 7], ['fe80::', 10], ['::', 128]]) blocked.addSubnet(addr, bits, 'ipv6');
function guardedLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const bad = addresses.find(a => blocked.check(a.address, a.family === 6 ? 'ipv6' : 'ipv4'));
if (bad) return callback(new Error(`blocked address ${bad.address}`));
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}
export function fetchPublic(rawUrl, allowedHosts) {
const url = new URL(rawUrl);
if (url.protocol !== 'https:') throw new Error('https only');
if (!allowedHosts.includes(url.hostname)) throw new Error('host not on the allow-list');
// Node does not call `lookup` for an IP literal, so the guard above would not run for one.
if (net.isIP(url.hostname.replace(/^\[|\]$/g, ''))) throw new Error('IP literals are not accepted');
return new Promise((resolve, reject) => {
const req = https.get(url, { lookup: guardedLookup, timeout: 5000 }, res => {
if (res.statusCode >= 300 && res.statusCode < 400) { res.resume(); return reject(new Error('redirect not followed')); }
const chunks = []; let size = 0;
res.on('data', c => { size += c.length; if (size > 1_000_000) req.destroy(new Error('response too large')); else chunks.push(c); });
res.on('end', () => resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString('utf8') }));
});
req.on('timeout', () => req.destroy(new Error('timeout')));
req.on('error', reject);
});
}
重点看 net.isIP(...) 那一行:它检查的是 new URL() 解析后的主机名,并先去掉 IPv6 的方括号。
作者第一版没有这行。首次测试中,域名指向本地地址时被拒绝,直接传 IP 却访问到了本地测试服务器。原因是 Node 遇到 IP 字面量时,不调用自定义 lookup,地址检查根本没有执行。
补上检查后,127.1、0x7f000001、2130706433、[::1] 这些被 URL 解析器规范化为 IP 的写法,都被同一行拒绝。
这次实测支持的结论很具体:防护必须用它打算拦截的输入来测试。
代码还设置了 timeout: 5000,收到超时事件后销毁请求,并把响应体限制为 1_000_000 字节。它们处理的是资源消耗问题,不能代替目标地址检查。
还有一个需要辨清的范围:虽然函数叫 fetchPublic,它仍要求 allowedHosts。因此这是带主机允许列表的示例,不能直接视为「任意公开网页抓取」的完整方案。作者也明确要求另外四层控制继续存在。
我的判断
我认同作者把审查问题改成「这个工具究竟能到哪里」。它能直接指导工具接口和部署配置,比继续堆叠提示词更容易形成可验证的边界。
但证据范围要收住。作者报告,发布前使用本地 HTTP 服务器和 stubbed client 做了测试:回环、私有、链路本地、运营商级 NAT 和 IPv6 唯一本地地址被阻断,两个公网地址未被阻断;localhost 在连接时被拒绝;多种 IP 写法、非 HTTPS URL、允许列表外主机也被拒绝。3xx 拒绝规则通过替身客户端单独测试。
这些结果验证了若干检查分支。真实 DNS 重绑定环境、仅 IPv6 网络、企业代理下的行为,作者明确没有测试。原文也没有给出 Node.js 具体版本,无法据此认定所有运行环境表现一致。
相比「通用 URL 工具加黑名单」,已知业务使用 ID 参数更容易控制权限,因为调用者可选择的目标更少。开放抓取则确实需要多层防线,但原文没有方案间的对照数据,也没有量化各层的拦截效果。应当把它看作有局部实测支持的工程建议,而不是经过完整环境验证的安全证明。
原文提到一份逐项请求测试表,但提供的内容没有展示表格,不能替作者补出测试结果。
能拿来做什么
- 检查现有 Agent 的 URL 参数。如果工具只查订单、工单或固定内部接口,就改为传业务 ID,由 Node 服务端构造请求,并在下游检查访问权限。
- 给网页抓取工具补一组回归用例,覆盖
localhost、不同 IP 写法、非 HTTPS、允许列表外主机和3xx,确认请求确实被阻止。测试应在自己拥有且获授权的环境里,通过 Agent 实际使用的工具入口执行。 - 从部署环境核对抓取进程的网络路由、cloud role 和环境凭证;能够拆分时,把它放进权限更小的独立进程,并配置网络出口限制。
- 上线前补测自己的特殊环境,尤其是企业代理、IPv6 和 DNS 重绑定。保留响应大小与超时控制,并分别验证它们是否按预期触发。