Cursor AI修复SSRF时先做DNS解析+私网IP段检查再发请求,但Node.js在建立socket时会二次解析DNS,攻击者可在第一次返回内网IP、第二次切回169.254.169.254,AI写出的「安全」代码依然可解。
让 AI 编辑器修复一个 SSRF 漏洞,它会写一个 DNS 查询、一个 IP 段检查,然后 fetch(url)。这个检查并不能堵住漏洞。
Node 在打开 socket 时会第二次解析主机名,所以攻击者的 nameserver 可以对检查返回公网 IP,而对实际连接返回 169.254.169.254。
在连接内部做校验,而不是在连接之前。然后开启 IMDSv2 和出口规则,让应用代码不再是 URL 参数和凭证之间的唯一屏障。
上周我让 Cursor 修复一个 SSRF。它立刻找到了漏洞,正确解释了 CWE-918,并用 URL 解析器、DNS 解析、私有地址段检查和禁用重定向重写了端点。看起来就像是出自安全指南的手笔。我差点就通过了。
它仍然可以被利用。不是因为检查逻辑有误,而是因为检查针对的 DNS 响应和请求针对的不是同一个。
这才是真正有意思的部分。这个漏洞的第一个版本是知识盲区。第二个版本不是。模型知道什么是 SSRF,知道缓解措施,写出的代码仍然会失败,因为失败发生在两行代码之间的缝隙里,而不是在某一行代码本身。
以下是那个"修复"后的版本,与我拿到的代码基本一致:
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
async function assertPublicUrl(raw) {
const u = new URL(raw);
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('scheme');
const { address } = await dns.lookup(u.hostname);
if (ipaddr.parse(address).range() !== 'unicast') throw new Error('private address');
return u;
}
app.get('/api/preview', async (req, res) => {
const u = await assertPublicUrl(req.query.url); // resolves once
const r = await fetch(u, { redirect: 'error' }); // resolves again
res.json({ title: extractTitle(await r.text()) });
});
把最后两行当成一对来读。assertPublicUrl 解析主机名并验证它得到的地址。然后把这个地址丢弃,返回一个持有主机名字符串的 URL 对象。fetch 拿到主机名,在打开 socket 时独立地再次解析它。
两次查询。第一次查询的结论不会带入第二次。那个缝隙就是漏洞所在,它有自己的分类:CWE-367,检查时间到使用时间(time-of-check to time-of-use)。SSRF(CWE-918)实际上从未被真正修复,只是在它前面拧上了一个校验器。
为什么这个问题不断发生
攻击手段是 DNS 重新绑定,而且成本很低。攻击者拥有一个域名,并为自己的权威 nameserver 设置 TTL 为零。你的检查请求 attacker.example,返回一个普通的公网 IP,通过。几毫秒后 fetch 请求同一个域名,得到 169.254.169.254。你的服务器读取云元数据服务并返回它找到的任何内容。
有一个 Node 特有的细节让问题比表面看起来更严重。Global fetch 由 undici 驱动,而 undici 完全不遵守 Node 的 agent 选项——它会静默忽略它,在连接时自己解析主机名。所以另一种常见的修复尝试,把验证后的 IP 钉在一个自定义 http.Agent 上,在 fetch 上悄悄不起作用。代码看起来像是 IP 被钉住了。实际上没有。
这对于小心翼翼的团队来说也不是一种假设性的失败模式。Budibase 在其 REST 数据源集成中交付了这个类型的漏洞,收到了通报,修复了,然后又收到了第二份通报,因为其他路径上仍有绕过。一个已经知道这种精确攻击、已有 CVE 在手的团队,在后续检查中仍然漏掉了它。这就是这里的难度。
至于模型为什么会这样写:训练语料里充满了"在 fetch 之前验证 URL"这种建议。那句话说的是 URL。漏洞说的是 socket。公开写作中几乎没有什么把校验器和最终发生的连接联系起来,所以模型生成的是一个接收字符串、检查字符串、返回字符串的函数。每个部分都是符合惯用法的。组合起来却是错的。
代码审查中的端倪:如果被验证的 IP 地址不是你实际连接的地址,那你什么也没验证。
检查必须运行在连接路径内部,针对 socket 实际即将使用的地址。在 Node 中,这意味着在一个 undici Agent 上自定义 lookup,通过 dispatcher 交给 fetch:
import { Agent } from 'undici';
import dns from 'node:dns';
import ipaddr from 'ipaddr.js';
function safeLookup(hostname, options, cb) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return cb(err);
if (ipaddr.parse(address).range() !== 'unicast') {
return cb(new Error('blocked: non-public address'));
}
cb(null, address, family);
});
}
const safeAgent = new Agent({ connect: { lookup: safeLookup } });
app.get('/api/preview', async (req, res) => {
const u = new URL(req.query.url);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return res.status(400).end();
const r = await fetch(u, { dispatcher: safeAgent, redirect: 'error' });
res.json({ title: extractTitle(await r.text()) });
});
ipaddr.parse(address).range() !== 'unicast' 一行拒绝 loopback、私有地址、链路本地和保留地址段,链路本地正是 169.254.169.254 所在的位置。重要的改变不是检查本身,而是它的位置。safeLookup 在客户端打开的每个连接上都会运行,所以在检查和连接之间没有窗口,也没有重定向跳可以溜过的独立路径。
Python 有相同形态的问题。requests 在 adapter 内部解析主机名,所以你在之前验证的任何东西都只是建议性的。两个正经的选项是:自定义 HTTPAdapter,其池管理器在连接时验证解析后的地址;或者自己解析一次,直接用验证后的 IP 连接,并显式设置 Host header 和 TLS server hostname。
现在说比代码更重要的部分。应用层 URL 校验是三层可用控制中最弱的一层,而且它是 AI 编辑器唯一会替你写的。
开启 IMDSv2 并要求它(http_tokens = required)。有了 IMDSv2,读取元数据需要先发一个 PUT 到 /latest/api/token,带上 X-aws-ec2-metadata-token-ttl-seconds header,然后用返回的 token 在 GET 请求的 X-aws-ec2-metadata-token header 中。一个典型的 SSRF 控制一个 URL,而不是方法和 header,所以它无法完成那个握手。默认的 hop limit 为 1 也阻止 token 在超过一个网络跳之外使用。
然后加上出口规则,让 169.254.0.0/16 和 RFC1918 范围从获取用户提供 URL 的服务中完全不可达。这个控制不在乎你的校验器是否正确。
问:在 fetch 前检查解析后的 IP 能阻止 SSRF 吗?答:单凭它不行。如果检查和请求是两次独立的 DNS 查询,拥有零 TTL 记录的攻击者可以对检查返回一个公网地址,对请求返回一个私有地址。验证必须发生在 socket 实际连接的地址上。
问:redirect: 'error' 就够了吗?答:不够。它堵住了一个绕过,即一个允许的公网主机 302 到你内部,你应该保留它。它对重新绑定没有任何作用,因为这里没有重定向——同一个主机名第二次只是解析出了不同的结果。
问:IMDSv2 让 SSRF 安全了吗?答:没有。它通过要求一个大多数 SSRF 原语无法提供的方法和 header,移除一个高价值目标。你私有网络上的内部管理面板、数据库和服务间端点仍然可达。
我一直用 SafeWeave 来做这件事,通过 MCP server 集成到 Cursor 和 Claude Code 中,所以 fetch 路径在我还在看代码时就被标记出来。不过我会直说局限性:这个特定的漏洞对任何模式匹配扫描器来说都很难,因为漏洞代码里包含了一个校验器,因此看起来就像是修复。最终能站住脚的控制都在应用之下——强制开启 IMDSv2,以及让元数据端点在服务中首先就不可达的出口规则。把这些做好,校验器里的一个错误就不再是凭证泄露。