AI 生成代码引发越权事故:普通账号导出全部订单的根因复盘

作者:袖梨 2026-09-21

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 生成的代码里,根本没有"当前用户是谁"和"订单归属谁"这两个概念

看 AI 的代码就明白了——它把 userId 当成一个从前端传进来的查询参数,跟 startDateendDate 一样。AI 的上下文里只有"这个接口接收哪些参数",没有"这些参数里哪个必须由服务端身份决定、不能由用户自己传"。

在 AI 的认知里,"能查到数据"就约等于"该返回数据"。它不知道订单是分属不同用户的、不知道这个接口背后有"谁导出谁的"这种边界。这不是 AI 故意埋洞,是它的上下文里压根没有这些信息。

所以这个翻车的责任,与其说在"AI 没写鉴权",不如说在"没人做安全审查"。我 review 时只看了逻辑对不对(能查、能导出、能分页),没看信任边界(这个数据该归谁)。这正好是我在另一篇里说的——审 AI 代码,不能只看"对不对",还要看"边界在哪"。

修复:两个版本,一个看似修了其实没修

修复越权,核心就是加"归属校验"——只允许用户导自己的订单。但这里有个非常容易踩的坑,我差点就踩进去了。

错误修复版:从 query 挪到请求头,以为换个地方就安全

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 特别容易埋这种洞"说透。

因为 AI 生成接口的思考路径是"接口接收什么参数 → 就按参数去查"。它把 userIdstartDateendDate 一视同仁,都是"查询条件"。它没有"哪些参数该由服务端身份决定"这个认知——它的上下文里没有信任边界

人写代码时,会带着"这是登录用户的 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/:idorderId 读他人订单查出后校验 order.ownerId === currentUserId,不能直接按 id 返回
修改/取消订单 PUT /api/orders/:idorderId 修改或取消他人订单先校验归属再执行;更新/删除语句里同时带 userId 条件
删除资源 DELETE /api/files/:id:id 删掉他人文件、评论、地址删除前必须归属校验;批量删除要逐条校验,不能只校验第一条
下载文件/附件 GET /api/files/:id/downloadfileId 拉取合同、发票等敏感附件校验文件归属或授权列表,不能只校验“已登录”
后台管理接口 GET /api/admin/users普通用户访问管理能力只做归属校验不够,必须再加角色/权限校验(RBAC)
跨租户数据 GET /api/tenants/:tenantId/...tenantId 跨租户读数据所有查询强制带租户上下文,并校验当前用户所属租户

这些接口的共同点都一样:路径或参数里出现了 userIdorderIdfileId 这类资源 id。AI 很容易把它们当成普通查询条件,直接拼进查询或更新语句。审的时候只要看到资源 id,第一反应就应该是:这个 id 是服务端身份解析出来的吗?资源归属校验做了吗?

这套修复的边界

归属校验能挡住"普通用户越权访问别人资源"(IDOR)这一类,但要说清楚它覆盖不到什么。

不覆盖横向越权里更复杂的权限模型。 比如多级角色(普通用户 / 运营 / 管理员)、租户隔离(一个平台多个租户)、部门数据权限。这些不是"一个 userId 校验"能解决的,需要完整的权限模型(RBAC / ABAC)。

不是所有接口都要归属校验。 公开数据、不需要登录的接口,强行加归属校验反而是错的。审查时要分清"这个数据本来就该公开"还是"这个数据有归属"。

我整理了一张简单的判断表,审接口时直接套:

接口类型要归属校验吗为什么
用户导出自己的订单 / 报表数据分属不同用户
用户查自己的资料 / 订单详情数据有归属
后台管理导出全量✅(且要管理员角色)除了归属,还要角色校验
公开商品列表 / 公告本就公开
跨租户共享数据✅(租户隔离)归属到租户级别

只挡越权,不挡别的洞。 校验的是"这个资源该不该给你看",挡不了参数注入、业务逻辑漏洞这类别的问题。

总结一下

回头看这个翻车,其实是个特别典型的 AI 代码安全坑:代码功能全对,但边界错了——它把"用户身份"当成了一个可以随便传的查询参数。

根因不在 AI 故意埋洞,而在没人做安全审查。我 review 的时候只看了逻辑对不对,没看信任边界在哪。

这个教训我后来沉淀成一句话,也写进了主推那篇:审 AI 生成的接口,身份边界是第一个要看的——资源 id 必须来自服务端身份,不能从前端任何可控的位置拿。这条过了,越权这一类洞基本就堵住了。

相关文章

精彩推荐