AI 生成的接口往往能迅速完成查询、分页和文件导出,却可能在最关键的权限边界上留下空缺。订单导出功能上线后,普通账号只需修改请求中的用户标识便能获取他人数据。这个案例需要解决的并非查询逻辑,而是身份来源、资源归属校验以及安全回归测试。
摘要: 一个“用户导出自己的订单”接口,上线第二天就被安全测试用普通账号拉走全量订单。代码能查、能分页、能导出,唯独缺了接口鉴权,属于典型 IDOR 越权漏洞。根因是 AI 代码安全上下文里没有“当前用户是谁、订单归属谁”,以为能查到就能看。本文复盘完整排查与修复,对照 OWASP Top 10,指出身份必须由服务端会话决定。
那个接口是"导出订单",给用户自己下载订单明细用的。需求不复杂——用户选时间范围,导出自己名下的订单,做成 Excel 或者 CSV。
AI 写的代码。我看了一眼,逻辑都对:能按条件查、能分页、能拼 CSV、能设置文件名。功能测试也过了,就发了。
上线第二天,安全测试的同事随手点了这个导出接口,用另一个普通账号试了试,结果导出了全量订单。不是当前账号的,是所有人的。
我当时第一反应是"这不怪 AI 吧,是我没让它做权限"。但往下查了才发现,事情没那么简单。
先说清楚这个接口的定位,不然容易混淆——它设计上就是"用户导出自己的订单",不是后台管理那种"导出全量数据"的接口。所以它该做的,是只允许当前用户导出自己名下的订单。
AI 的实现大概是这样的(去掉敏感信息后的简化版,示例基于 Node 20 + Express 5 + Sequelize 6):
// 为什么要看这段:AI 生成的导出接口,看着能查能导出,但没校验"导出的是谁的订单"
app.get('/api/orders/export', async (req, res) => {
const { userId, startDate, endDate } = req.query // ← userId 从前端传进来
const orders = await Order.findAll({
where: {
userId: userId, // ← 只按前端传的 userId 查
createdAt: { between: [startDate, endDate] }
}
})
res.setHeader('Content-Type', 'text/csv')
res.setHeader('Content-Disposition', 'attachment; filename=orders.csv')
res.send(buildCsv(orders))
})
// 翻车时的运行结果
正常调用:/api/orders/export?userId=1001&startDate=...&endDate=...
→ 查 userId=1001 的订单,正常导出自己那份
恶意调用:/api/orders/export?userId=1002&startDate=...&endDate=...
→ 照样查 userId=1002 的订单并返回 ← 越权,别人的数据被导出来了
功能上它完全正常:能查、能分页、能导出、文件名也对。问题在于——它只按前端传进来的 userId 查,没有校验"这个 userId 是不是当前登录的用户"。
我把 userId 改成别人的,接口就返回别人的订单。这就是典型的 IDOR(越权访问)漏洞,对应 OWASP Top 10(2021)里的 A01 失效的访问控制,在"高危漏洞"里常年排第一。
排查其实很顺,因为问题很直接。我没去翻日志、没去抓包——我看了一眼代码就明白了。
但"看一眼就明白"这件事本身让我不舒服。我在想:为什么我当时 review 没看出来?功能测试为什么也没测出来?
功能测试测的是"正常路径"——登录一个用户,导出自己的订单,能导出就通过。没人去测"用 A 的登录态,去导 B 的订单"。安全测试测的恰恰是这条反例路径,所以一测就穿。
这条越权路径画成时序图如下:
sequenceDiagram
participant A["攻击者"]
participant S["服务端"]
participant D["数据库"]
A->>S: "GET /api/orders/export?userId=1002&startDate=...&endDate=..."
Note right of S: "漏洞点1:userId 直接来自前端 query,可被任意篡改"
S->>D: "SELECT 订单 WHERE userId = 1002"
D-->>S: "返回 userId=1002 的订单数据"
Note right of S: "漏洞点2:服务端未校验该 userId 是否等于当前登录用户"
S-->>A: "返回 1002 的全量订单 CSV"
Note over A,S: "结果:攻击者成功越权导出他人订单"
这个洞的根因,值得单独说清楚。
我一开始以为责任全在"我没让 AI 做鉴权"。但复盘下来,更准确的说法是:AI 生成的代码里,根本没有"当前用户是谁"和"订单归属谁"这两个概念。
看 AI 的代码就明白了——它把 userId 当成一个从前端传进来的查询参数,跟 startDate、endDate 一样。AI 的上下文里只有"这个接口接收哪些参数",没有"这些参数里哪个必须由服务端身份决定、不能由用户自己传"。
在 AI 的认知里,"能查到数据"就约等于"该返回数据"。它不知道订单是分属不同用户的、不知道这个接口背后有"谁导出谁的"这种边界。这不是 AI 故意埋洞,是它的上下文里压根没有这些信息。
所以这个翻车的责任,与其说在"AI 没写鉴权",不如说在"没人做安全审查"。我 review 时只看了逻辑对不对(能查、能导出、能分页),没看信任边界(这个数据该归谁)。这正好是我在另一篇里说的——审 AI 代码,不能只看"对不对",还要看"边界在哪"。
修复越权,核心就是加"归属校验"——只允许用户导自己的订单。但这里有个非常容易踩的坑,我差点就踩进去了。
AI(或者说第一次想省事的我)给出的"修复"是这样——它意识到 userId 放在 URL 参数里不好,于是把它挪到了请求头,以为"不在 URL 里了,别人就改不了了":
// 错误修复版:看似修了,其实只是把 userId 从 query 挪到了 header
app.get('/api/orders/export', async (req, res) => {
// 以为放到 header 就安全,其实 header 也是前端可控的
const userId = req.headers['x-user-id'] // ← 还是前端传的,只是换了个地方
const { startDate, endDate } = req.query
const orders = await Order.findAll({
where: {
userId: userId, // ← 依然用前端传的 userId
createdAt: { between: [startDate, endDate] }
}
})
// ... 导出 orders
})
// 错误修复版运行结果
恶意调用:Header: x-user-id: 1002
→ 攻击者直接改请求头里的 x-user-id
→ 照样导出 1002 的订单 ← 还是没修,只是从 query 换到了 header
看出问题了吗?请求头也是前端可以随便设的,跟 query 参数一样不可信。从 URL 挪到 header,等于把门从左边挪到右边,门还是开着的。判断"这个身份可不可信",不是看它放在 URL 还是 header,而是看它是不是由服务端自己认出来的。凡是从前端任何可控位置(query / header / body)拿到的 userId,都是不可信的。
正确的做法是:"当前用户是谁"必须由服务端从登录会话 / token 里解析出来,绝不能从请求体里拿。前端传的 userId 直接忽略,永远用服务端认出的那个身份:
// 正确修复版:userId 必须来自服务端会话,忽略前端传的值
app.get('/api/orders/export', async (req, res) => {
// 身份从服务端会话/token 解析,不是从前端请求体拿
const currentUserId = req.session.user.id // ← 服务端认出的身份
const orders = await Order.findAll({
where: {
userId: currentUserId, // ← 永远用服务端身份,忽略 query 里的 userId
createdAt: { between: [req.query.startDate, req.query.endDate] }
}
})
res.setHeader('Content-Type', 'text/csv')
res.send(buildCsv(orders))
})
// 正确修复版运行结果
恶意调用:/api/orders/export?userId=1002&...
→ currentUserId 从会话解析 = 登录用户自己的 id(比如 1001)
→ 忽略前端传的 userId=1002,只查 1001 的订单
→ 返回 1001 的订单 ← 越权被拦下,拿不到 1002 的数据
修复前后的差异,画成图就是这样的:
修复前:
浏览器(随便填userId) ──> 服务端 ──> 查 userId=用户填的 ──> 返回任意人的数据
修复后:
浏览器 ──> 服务端(从会话解析出当前用户) ──> 只查当前用户 ──> 返回本人的数据
│
userId 由服务端决定,忽略前端传的
三个版本一对比,差别就清楚了:
| 版本 | userId 来源 | 攻击者能控制吗 | 结果 |
|---|---|---|---|
| 翻车版 | URL 参数 ?userId= | ✅ 能 | 越权,拉全量 |
| 错误修复版 | 请求头 x-user-id | ✅ 能(只是换个位置) | 还是越权 |
| 正确修复版 | 服务端会话 | ❌ 不能 | 只查自己的 |
判断"身份可不可信",不是看它放在 URL 还是 header,而是看它是不是由服务端自己认出来的。这一条我现在每审一个 AI 生成的接口都会过一遍,之前就吃过没查的亏。
光改代码还不够,得把这条反例路径固化进测试,防止以后又被 AI 或者粗心的改动带回去:
// 为什么要看这段:把越权反例路径固化成测试,防止回归
it('普通用户不能导出别人的订单', async () => {
const loginUser = await loginAs('user_1001')
const res = await loginUser.get('/api/orders/export?userId=1002')
// 正确修复后:返回的应该是自己(1001)的订单,不是 1002 的
const data = res.body
expect(data.every((row: any) => row.userId === 1001)).toBe(true)
})
// 测试运行结果
普通用户导出 userId=1002 的接口
→ 返回的全是 userId=1001 的数据(服务端身份)
→ 断言通过 ✅(如果哪天又被改回从请求体拿 userId,这条测试会立刻红)
这条测试的价值是:它把"反例路径"(越权)变成了常规回归,而不只是靠人记得去测。
复盘完,我想把"为什么 AI 特别容易埋这种洞"说透。
因为 AI 生成接口的思考路径是"接口接收什么参数 → 就按参数去查"。它把 userId、startDate、endDate 一视同仁,都是"查询条件"。它没有"哪些参数该由服务端身份决定"这个认知——它的上下文里没有信任边界。
人写代码时,会带着"这是登录用户的 id,得从 session 拿"这种默认认知。AI 没有,它只会按 prompt 里给的信息和"最常见写法"来。
所以审 AI 生成的接口,身份边界这块要专门看:凡是接收 userId / ownerId / 各种资源 id 的接口,都要确认这些 id 是服务端解析出来的,不是前端随便传的。这是我在安全审查那篇里说的"身份边界",这里算是一个完整的事故案例。
导出订单只是其中一个例子。实际审 AI 生成的接口时,这些带“资源 id”的接口都是 IDOR 高发点,可以对照着查:
| 接口类型 | 高风险点 | 归属校验要点 |
|---|---|---|
查看用户详情 GET /api/users/:id | 改 :id 看别人的手机号、邮箱、地址 | 当前用户身份从会话取,路径里的 :id 必须等于当前用户,否则 403 |
修改用户资料 PUT /api/users/:id | 改 :id 篡改他人昵称、密码、绑定手机 | 只认服务端会话身份;密码、手机等敏感字段还要二次校验 |
查看订单/详情 GET /api/orders/:id | 改 orderId 读他人订单 | 查出后校验 order.ownerId === currentUserId,不能直接按 id 返回 |
修改/取消订单 PUT /api/orders/:id | 改 orderId 修改或取消他人订单 | 先校验归属再执行;更新/删除语句里同时带 userId 条件 |
删除资源 DELETE /api/files/:id | 改 :id 删掉他人文件、评论、地址 | 删除前必须归属校验;批量删除要逐条校验,不能只校验第一条 |
下载文件/附件 GET /api/files/:id/download | 改 fileId 拉取合同、发票等敏感附件 | 校验文件归属或授权列表,不能只校验“已登录” |
后台管理接口 GET /api/admin/users | 普通用户访问管理能力 | 只做归属校验不够,必须再加角色/权限校验(RBAC) |
跨租户数据 GET /api/tenants/:tenantId/... | 改 tenantId 跨租户读数据 | 所有查询强制带租户上下文,并校验当前用户所属租户 |
这些接口的共同点都一样:路径或参数里出现了 userId、orderId、fileId 这类资源 id。AI 很容易把它们当成普通查询条件,直接拼进查询或更新语句。审的时候只要看到资源 id,第一反应就应该是:这个 id 是服务端身份解析出来的吗?资源归属校验做了吗?
归属校验能挡住"普通用户越权访问别人资源"(IDOR)这一类,但要说清楚它覆盖不到什么。
不覆盖横向越权里更复杂的权限模型。 比如多级角色(普通用户 / 运营 / 管理员)、租户隔离(一个平台多个租户)、部门数据权限。这些不是"一个 userId 校验"能解决的,需要完整的权限模型(RBAC / ABAC)。
不是所有接口都要归属校验。 公开数据、不需要登录的接口,强行加归属校验反而是错的。审查时要分清"这个数据本来就该公开"还是"这个数据有归属"。
我整理了一张简单的判断表,审接口时直接套:
| 接口类型 | 要归属校验吗 | 为什么 |
|---|---|---|
| 用户导出自己的订单 / 报表 | ✅ | 数据分属不同用户 |
| 用户查自己的资料 / 订单详情 | ✅ | 数据有归属 |
| 后台管理导出全量 | ✅(且要管理员角色) | 除了归属,还要角色校验 |
| 公开商品列表 / 公告 | ❌ | 本就公开 |
| 跨租户共享数据 | ✅(租户隔离) | 归属到租户级别 |
只挡越权,不挡别的洞。 校验的是"这个资源该不该给你看",挡不了参数注入、业务逻辑漏洞这类别的问题。
回头看这个翻车,其实是个特别典型的 AI 代码安全坑:代码功能全对,但边界错了——它把"用户身份"当成了一个可以随便传的查询参数。
根因不在 AI 故意埋洞,而在没人做安全审查。我 review 的时候只看了逻辑对不对,没看信任边界在哪。
这个教训我后来沉淀成一句话,也写进了主推那篇:审 AI 生成的接口,身份边界是第一个要看的——资源 id 必须来自服务端身份,不能从前端任何可控的位置拿。这条过了,越权这一类洞基本就堵住了。