评审一份接口设计文档时,最容易通过的往往是那些看起来最普通的接口:GET /orders/{id}、PATCH /profiles/{id}、DELETE /files/{id}。文档里写了"需要登录后访问",评审就过了。但"登录后访问"和"只能访问自己的对象"是两回事,把 URL 里的 1001 换成 1002 就能读到别人订单的问题,正是 OWASP API 安全 Top 10 里排在 API1:2023 的失效对象级授权(BOLA)——接口没有校验已认证用户是否有权访问这个具体对象。这类缺陷不需要绕过认证,不留下异常特征,靠上线后的扫描去补往往已经晚了。下面这份清单的用途很直接:下次评审接口设计文档时,逐条对着接口列表勾。

为什么越权总是从设计阶段漏出去
设计文档的写法决定了它会漏掉什么。多数接口文档的结构是路径、方法、请求参数、响应字段、错误码,认证一栏通常只有"需要 Token"这一句。这套模板天然记录"谁能调这个接口",却没有位置记录"这个用户能操作哪些对象"。于是授权判断被默认推给了实现阶段,而实现阶段的开发者看到文档里 id 是必填参数,最自然的写法就是拿着 id 去查库返回。
第二个原因是职责错位。认证由网关、中间件或统一框架层完成,看起来"已经有一层安全",但网关只知道请求带着一个有效身份,不知道路径里那个 ID 属于谁——对象归属关系存在业务数据里,只有业务代码能判断。任何把授权指望在统一入口一次解决的设计,在按 ID 操作的接口上都会留下缺口。
第三个原因是评审视角。评审时大家更容易讨论字段命名、分页方式、幂等性和错误码,因为这些有明确的对错;而"越权"要靠想象一个恶意调用者才能发现。所以有效的做法不是提醒大家"注意安全",而是把三个必答问题写进评审清单,让漏答变成一个可见的缺项。
第一步:每个按对象 ID 操作的接口,是否校验归属权
先把接口清单过一遍,标出所有请求参数中包含对象标识的接口——路径参数、查询参数、请求体字段、批量数组里的 ID,都算。这一步的输出应该是一张明确的"受保护对象接口表",而不是印象中的"大概几个"。批量接口和嵌套资源最容易被忽略:一个接受 ID 数组的批量删除接口,如果只校验了数组里第一个元素,等于没校验。
对每个标出的接口,文档里必须能回答三件事:这个对象归属于谁(用户、租户、组织、还是某个上级资源);归属关系从哪里查出来(对象自身字段、关联表、还是外部服务);校验失败时返回什么。归属关系的表述必须落到具体字段或关联路径,"由业务层判断权限"这种描述在评审时应当直接退回。
实现层面的关键是把归属条件写进数据访问本身,而不是先查出对象再比对。查询条件同时包含对象标识和当前身份,越权请求自然查不到数据:
# 有缺陷的写法:先取对象,再看要不要拦
order = repo.find_by_id(order_id)
return order # 没有任何归属判断
# 修正示意:归属条件进入查询条件
order = repo.find_one(id=order_id, owner_id=current_user.id)
if order is None:
raise NotFound() # 不存在与无权访问返回一致
返回码的选择也值得在文档里定下来。对无权访问的对象返回"不存在",可以避免通过响应差异确认对象是否存在;如果业务上必须区分,就要明确这是有意接受的信息暴露。另一个常被提到的辅助手段是用不可预测的标识替代连续自增 ID,但它只增加猜测成本,不能替代归属校验,评审时不应把"我们用了随机 ID"当作这一项的答案。
第二步:列表与详情接口,是否按最小权限裁剪字段
列表接口的风险和详情接口不同:它通常没有对象 ID 参数,越权发生在查询范围上。评审时要确认列表查询的过滤条件是服务端根据当前身份强制附加的,还是来自请求参数。凡是由客户端传入 user_id、tenant_id、org_id 之类的范围参数并直接进入查询的设计,都要求给出服务端二次校验的说明;能从会话身份推导的范围参数,最好在设计阶段就从接口签名里删掉。
排序、筛选、搜索关键字和导出功能是同一问题的变体。允许按任意字段筛选,等于把数据库查询能力开放给调用者;允许导出全量,等于把分页限制绕过。这些能力如果保留,就需要写明可用字段白名单和范围约束。
字段维度还有一层更细的问题——对象内部的属性授权。同一个对象,不同角色能看到的字段不该相同:客服看得到订单联系人,普通用户不该看到风控标记或内部备注。稳妥的做法是响应结构按调用者角色显式定义,用输出模型组装返回内容,而不是把数据库实体直接序列化。写入方向同理,更新接口应当明确可写字段白名单,避免请求体里多带一个 role 或 owner_id 就改掉了归属关系。评审时可以问一句:这个响应结构是列出来的,还是模型直接吐出来的?
第三步:更新与删除接口,是否重复鉴权
读接口的越权是数据泄露,写接口的越权是数据被改写或删除,后者往往不可回滚。但写接口恰恰更容易漏检,因为流程里常常已经有过一次校验:详情页打开时校验过,提交时就默认"这个对象已经确认过归属"。这种依赖前序请求的假设在 API 场景下不成立——调用者可以直接构造提交请求,跳过任何前置步骤。
所以规则很简单:每一次状态变更都要独立完成归属校验,把校验条件写进更新和删除语句的条件里,而不是先查再改。多步流程(草稿、预校验、确认提交)的每一步都要各自校验,不要把中间态令牌当作授权凭据。状态机类接口还要加一层判断:当前身份是否有权执行这个状态转换,而不只是是否拥有这个对象——拥有一个订单并不意味着可以把它改成"已退款"。
写接口的评审还应确认审计记录是否包含操作者身份、目标对象标识和结果。归属校验失败的请求同样值得记录,集中出现的失败往往是有人在遍历 ID。
典型漏检场景与整改方向
把下面这些场景直接当作评审时的对照项,出现一条就回到对应的检查步骤:
| 漏检场景 | 为什么会被放过 | 整改方向 |
|---|---|---|
| 嵌套资源只校验了父对象 | 认为路径前缀已经限定范围 | 校验子对象确实属于该父对象,且父对象属于当前身份 |
| 批量接口按数组接收 ID | 单个接口有校验,批量走了另一条代码路径 | 逐元素校验,任一失败即整批拒绝 |
| 客户端传入范围参数 | 前端本来就只会传自己的 ID | 范围从会话身份推导,或服务端二次比对 |
| 内部接口、管理后台接口 | 认为不对外暴露 | 按同等标准校验,不依赖网络位置作为授权 |
| 文件下载与导出链接 | 只校验了链接本身有效 | 校验请求者对文件所属对象的权限,并限制有效期 |
| 第三方回调与集成接口 | 有签名校验就算通过 | 签名只证明来源,仍需校验对象归属与可操作范围 |
| 多租户共用查询层 | 租户字段在上层拼装 | 租户隔离下沉到数据访问层,禁止绕过的查询入口 |
整改的共同点是把授权条件从"流程中的某一步"变成"数据访问的固有条件"。只要还存在一条能拿到对象却不带身份约束的查询路径,就迟早会有接口调用它。
让清单持续生效
一次性评审只能清理存量。要让它在后续迭代中继续起作用,可以把检查绑到变更上:凡是新增或修改了接口、认证方式、数据模型的改动,都要求同时更新受保护对象接口表,并在代码评审中回答归属校验写在哪里。有公开的实践建议提出,在每个涉及接口、认证或数据模型变更的合并请求上运行接口规格审计与动态测试,对高风险接口在预发环境定期做一轮 BOLA、认证与注入的回归测试,生产环境保持运行时防护常开。这套节奏是否完全照搬要看团队规模,但方向值得参考:授权测试的触发条件应该是"接口变了",不是"发版前想起来了"。
测试用例的写法也建议固定下来:为每个受保护对象接口准备两个身份,用 A 的凭据访问 B 的对象,断言拿不到数据也改不动状态。这类用例写起来不复杂,价值在于它会随接口一起长期存在,新接口漏了校验时会立刻失败。
下一步可以从最小动作开始:打开当前的接口设计文档,找出所有带对象标识的接口,在每一条后面补上"归属字段"和"校验位置"两栏。填不出来的那几行,就是这次评审真正要讨论的内容。

甘肃省 1F
把归属条件写进查询条件这条太实用了