Cursor在修复IDOR漏洞时只覆盖了GET路由,PATCH、DELETE和列表端点仍存在未授权访问风险;真正的修复需要在数据访问层统一加入权限域检查。
让 AI 编辑器修复一个 IDOR,它会把你展示给它的那个路由正确地限定作用域,然后留下 PATCH、DELETE 和列表端点继续在主键查询上裸奔。
未加作用域限定的列表端点是更危险的那一半。IDOR 通常需要攻击者付出一些 ID 枚举的代价,而一个返回所有人记录的列表端点,把这些 ID 白白送给了攻击者。
真正有效的修复是结构层面的:一条所有处理器都必须经过的、限定了 owner 的访问路径,这样新路由在写的时候不可能漏掉检查。
上周我让 Cursor 修复一个 IDOR。它做到了,修复也是正确的。
然后我往下滑。
它修复的那个处理器是 GET /api/invoices/:id。往下二十行,DELETE /api/invoices/:id 仍在调用 findByIdAndDelete(req.params.id)。在两者上方,GET /api/invoices 把数据库里每张发票都返回给了请求者。
修复是真的。只是它的影响范围只有一个路由。
AI 编辑器为 IDOR 写的修复是正确的,而且正确地仅限于那一个处理器。
以下是我提供给它的内容:
// Before - CWE-639: authorization bypass through user-controlled key
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findById(req.params.id);
res.json(invoice);
});
这是它返回的结果:
// After
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await Invoice.findOne({
_id: req.params.id,
userId: req.user.id, // ownership is now part of the query
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});
我要公平地评价它:这是正确的形态。ownership 条件移到了查询里,所以数据库来证明 ownership,而不是处理器来证明存在性。未命中返回 404 而不是 403,所以错误码不会确认别人的发票存在。如果只给这一个处理器打分,它通过了。
以下是同一文件中其余部分,原封不动:
app.get('/api/invoices', requireAuth, async (req, res) => {
res.json(await Invoice.find({})); // every user's invoices
});
app.patch('/api/invoices/:id', requireAuth, async (req, res) => {
const updated = await Invoice.findByIdAndUpdate(
req.params.id, req.body, { new: true }
);
res.json(updated);
});
app.delete('/api/invoices/:id', requireAuth, async (req, res) => {
await Invoice.findByIdAndDelete(req.params.id);
res.status(204).end();
});
同一资源。同样的 ownership 规则。三个从未听说过这条规则的处理器。
未加作用域限定的列表端点是危险的那个,因为它消除了利用未加限定的 delete 的唯一真正代价。
写路由上的 IDOR 通常不是一步到位的利用。攻击者必须先拿到有效的 ID,而难度完全取决于你的 ID 方案。顺序整数几乎零成本。随机 UUID 成本很高。你可以争论 Mongo ObjectId 在这条线上的位置。
但当 GET /api/invoices 直接把它们返回出来时,你就不必争论了。
GET /api/invoices -> 200, every invoice in the system, each with its _id
DELETE /api/invoices/<any_id> -> 204
两个请求。第一个是枚举步骤,由你自己的 API 服务,通过认证,记录为正常读取。第二个是损害。这个序列在访问日志里没有任何看起来像攻击的痕迹。
而且这个修复在某个具体方面让情况变得更糟了,与代码无关。历史记录里现在有一条 commit 说发票上的 IDOR 已修复。下次谁在这个文件里 grep findById,会在顶部找到一个干净的、正确加了作用域限定的处理器,然后停止阅读。
编辑器在它的上下文窗口里打了补丁,而 ownership 不是一段代码的属性。
这就是全部机制。你粘贴了一个处理器,问它哪里有问题。模型回答了你问的问题,关于你展示给它的代码,答案也是对的。但授权是资源的属性,不是任意单个路由的属性,而且只有每条触及该资源的路径都携带相同的谓词时,授权才有效。模型的单位是面前的那块代码。ownership 规则的工作单位是全部四个处理器,加上你下个月会写的两个,加上后台任务。
这两个单位没有对齐,而交互中没有任何东西告诉你它们没有对齐。你得到了一个自信的、看起来正确的 diff,针对的是你指向的那个东西。
这不是 AI 特有的失败,这是值得停下来思考的部分。人类也在不停地犯这个。2026 年 6 月披露的 CVE-2026-47418,cvss 8.1,涉及 praisonai-platform,恰好就是这个形态,故事里没有任何 AI 编辑器。项目的 GET / PATCH / DELETE /workspaces/{workspace_id}/projects/{project_id} 路由确实检查了什么东西:它们用 require_workspace_member(workspace_id) 做门控。然后它们通过 ProjectService.get(project_id) 解析对象,那是 session.get(Project, project_id),一个主键查询,在整个调用链上没有任何 workspace_id 谓词。任何 workspace 的成员都可以读取、修改或删除属于另一个 workspace 的项目。
这个 advisory 里有两个细节,是我用它而不是用一个更干净的例子的原因。
第一:update 和 delete 先调用 self.get(project_id),然后继承了那个缺口。一个未加限定的读取函数悄无声息地变成了三个未加限定的操作。这就是"修复读路径"在写路径也路由经过它时所购买的代价。
第二:它在一个代码库里发生了三次。CVE-2026-47415 是 issues 上同样的 bug,CVE-2026-47419 是 agents 上同样的 bug。advisory 直接这样说了,称根本原因与本代码库中的 agent 和 issue IDOR 完全相同。三个 CVE ID,一个结构错误,按资源类型在同一个团队身上重复,他们显然理解 membership 检查的概念,因为他们写了一个。
所以 AI 并没有做什么特别蠢的事。它在以你能生成路由的速度再生产 web 开发中最常见的授权错误,这个速度远快于你能审查它们的速度。
在 Django REST Framework 里,两个可以强制执行 ownership 的机制覆盖了不同的操作集,而且没有一个覆盖全部。
这是有文档记录的,不是民间传说。DRF 自己的权限指南明确指出,generic views 只在检索单个模型实例的 views 上检查对象级权限,列表视图的对象级过滤是你自己的责任。它还指出,因为 get_object() 在 create 时从不被调用,所以 has_object_permission() 在那里根本不会运行。
对着 actions 摊开来看:
has_object_permission() 覆盖 retrieve、update 和 destroy。不覆盖 list。不覆盖 create。
get_queryset() 覆盖 list、retrieve、update 和 destroy。不覆盖 create。
现在猜一下,当你粘贴一个详情视图并说"修复这个 IDOR"时,AI 编辑器会伸手拿哪个。
它写权限类。当然会。你说了 IDOR 这个词,修复是一个授权修复,而在 DRF 里名字带授权的那个对象是 has_object_permission。它是更像安全答案的那个。它也是那个有列表端点漏洞的。
# 当你请求 IDOR 修复时你会得到的
class IsOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.owner == request.user
class InvoiceViewSet(viewsets.ModelViewSet):
queryset = Invoice.objects.all() # <- list 仍然返回所有内容
permission_classes = [IsAuthenticated, IsOwner]
Retrieve、update 和 destroy 现在是真正安全的了。GET /invoices/ 仍然返回表里每张发票,因为那个 queryset 是未过滤的,而且权限类从未被按行咨询过。
把 ownership 放进一个访问路径,所有处理器都必须经过它,这样就不存在一个可以漏掉检查的地方。
关键不是那行代码。而是再也不存在一个可以忘掉它的地方。
在 DRF 里,这意味着限定 queryset 的作用域,因为它是两个机制中唯一覆盖 list action 的,然后单独处理 create:
class InvoiceViewSet(viewsets.ModelViewSet):
serializer_class = InvoiceSerializer
def get_queryset(self):
# covers list, retrieve, update, partial_update, destroy
return Invoice.objects.filter(owner=self.request.user)
def perform_create(self, serializer):
# create 永远不走 get_object,所以在这里设置 owner,
# 从 session 中取——永远不要从请求体中取
serializer.save(owner=self.request.user)
在 Express 和 Mongoose 里,同样的思路是一个作用域辅助函数,处理器无法绕过它:
const owned = (req) => ({ userId: req.user.id });
app.get('/api/invoices', requireAuth, async (req, res) => {
res.json(await Invoice.find(owned(req)));
});
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const doc = await Invoice.findOne({ _id: req.params.id, ...owned(req) });
return doc ? res.json(doc) : res.status(404).json({ error: 'Not found' });
});
app.patch('/api/invoices/:id', requireAuth, async (req, res) => {
const doc = await Invoice.findOneAndUpdate(
{ _id: req.params.id, ...owned(req) },
{ $set: pick(req.body, ['amount', 'dueDate', 'notes']) }, // allowlist, not req.body
{ new: true }
);
return doc ? res.json(doc) : res.status(404).json({ error: 'Not found' });
});
app.delete('/api/invoices/:id', requireAuth, async (req, res) => {
const doc = await Invoice.findOneAndDelete({ _id: req.params.id, ...owned(req) });
return doc ? res.status(204).end() : res.status(404).json({ error: 'Not found' });
});
这里有三件事在做真正的工作,其中一件与 IDOR 完全无关。
每次未命中都返回相同的 404。不是 403。403 告诉调用者记录存在且属于别人,这把你的状态码变成了枚举 oracle,把限定作用域刚刚拿走的东西又还回去了一些。
写处理器使用 findOneAndUpdate 和 findOneAndDelete,而不是 findById 变体。这比看起来更重要。findByIdAndUpdate 把 id 作为第一个参数,所以没有位置放 ownership 谓词。API 形态悄悄地把向你推向未加限定的调用,而模型伸手拿的正是它,因为它是每个教程里都有的那个。
PATCH 处理器选择特定字段而不是展开 req.body。这不是 IDOR,这是 mass assignment,值得命名是因为它活在同一个处理器里,在 IDOR 修复后完好无损。限定查询的作用域可以阻止调用者编辑别人的发票。它对阻止他们在自己的发票上设置 userId 或 isPaid 毫无作用。
userId 过滤器精确地只建模一件事:单一所有者的资源。
一旦发票可以和会计共享,或者属于一个 workspace,或者对 org admin 可见,谓词就不再是列比较了。它是一个关系问题,而手工把它推进每个查询是你如何最终得到 CVE-2026-47418 的:路由上有一个真正的 membership 检查,下面是一个主键查询。到了那个地步,答案就是一个授权函数,接收 actor、action 和 resource,从一个地方调用,加一个测试,当路由到达数据库而没有经过它时让构建失败。
我不假装这一行代码能扩展到那种情况。它能扩展的是我们这周实际在构建的那个应用,也就是 AI 编辑器刚用九秒钟写了四个路由的那个。
Q:添加 auth middleware 能修复 IDOR 吗?A:不能。Authentication 证明的是谁在调用;IDOR 是 authorization 失败。检查必须证明调用者拥有那条特定记录,这意味着 ownership 条件属于数据库查询,而不是属于一个在记录被加载之前就运行的 middleware。
Q:为什么对属于别人的记录返回 404 而不是 403?A:403 确认了记录存在,这把你的错误码变成了枚举 oracle。无论 ID 不存在还是属于另一个用户,都返回相同的 404,这样响应就不会在任何意义上告诉攻击者任何信息。
Q:如果详情路由加了作用域限定,列表端点真的还是问题吗?A:它通常是更大的问题。未加限定的列表端点直接泄露每条记录,它还提供了有效的 ID,让未加限定的写路由可以被 trivial 地利用。尤其在 Django REST Framework 中,has_object_permission() 从不在 list action 上运行,所以一个正确的权限类在那里毫无作用。
我一直在用 SafeWeave 做这件事。它作为 MCP server 钩入 Cursor 和 Claude Code,在路由被写下的时刻而不是一天后在 CI 里标记那些把 ID 直接传入数据库调用而没有 ownership 谓词的路由处理器。不过我会坦诚地说说它的局限:缺失的 ownership 检查是缺席的条件,不是坏模式,所以没有任何扫描器能像 gitleaks 捕获活密钥那样捕获每一个变体。真正有效的是结构性的。所有处理器必须经过的一条限定了 owner 的访问路径,没有一个路由可以漏掉检查。