上一节讲会话管理时,我们一直在解决一个问题:服务器怎样在多次请求之间,持续认出同一个用户。到了这一节,问题会悄悄换一个方向。服务器已经认出你了,但它还得判断:你能做什么、能对谁的数据做、在什么条件下能做。
很多访问控制漏洞都发生在这里。请求带着完全合法的会话,接口也没有被“攻破”,只是服务器在拿到对象编号、操作名称或流程状态后,少问了一句“当前这个人真的有权这样做吗”。于是,普通成员看到了另一个成员的草稿,客服账号调用了管理员接口,或者申请人跳过复核直接批准了自己提交的申请。
这类测试特别容易从“验证一条边界”滑向“翻看更多数据”。所以我们先约定底线:本节只讨论书面授权的靶场、测试租户或项目方预先准备的虚构对象。我们要证明的是授权判断有没有失效,不是尽可能多地拿到数据。
访问控制测试必须使用范围内的专用账号与虚构对象。只做证明结论所需的最少请求。若响应意外出现真实用户数据,应立即停止扩大验证,遮盖敏感字段,并按约定通知系统负责人。

认证回答“你是谁”。会话管理让这个身份在后续请求中保持连续。授权回答的则是“这个身份能不能完成当前动作”。三者前后相连,却不能互相代替。
你可以把一次请求想成进入办公楼:认证像门卫核对工牌,会话像发给你的临时门禁卡,授权才是每一扇门上的权限判断。门禁卡是真的,不等于整栋楼的门都会打开。服务器确认用户已经登录,也不等于这个用户能访问应用中的所有对象和功能。
反过来也一样。公开页面允许未登录访问,并不代表系统“没有授权”;它只是有一条明确规则:匿名身份可以读取这项公开资源。授权不是“登录以后才发生的额外检查”,而是服务器对每次访问做出的允许或拒绝决定。
把授权规则压缩成一句话,就是:
主体 S 在上下文 C 中,能否对资源 R 执行动作 A?只检查角色,往往不够。两个用户都是“成员”,仍然不应该互相编辑私有草稿;主管可以批准团队申请,也未必能批准自己提交的申请;档案管理员能读取已归档记录,却不一定能把它改回草稿。

我们用三个方向描述常见边界:
这些分类用于帮助我们找测试方向,不是互斥标签。一条请求可能同时跨越对象归属、角色和状态三道边界。测试记录最好写清四个维度,而不是只写“存在越权”。
按角色分配权限很常见。角色相对稳定,适合表达“审计员只能读、运营人员可以发布、管理员可以配置”这类职责规则。但真实业务经常比角色更细。若系统为每一种部门、项目、地域和流程阶段都新建角色,角色数量会迅速膨胀,最后没人说得清某个角色到底意味着什么。
属性规则会把主体、对象、动作和环境条件一起带入判断。例如“同租户的主管,在工作时间内,可以读取本部门已提交的申请”。关系规则则关心主体与资源之间的连接,例如“创建者可编辑”“项目成员可评论”“被明确分享的用户可读取”。实际系统可以组合这些思路:用角色定义大的职责范围,再用对象关系与上下文属性收紧到当前请求。
不管采用哪种模型,最终都必须落到服务端的一个明确结论:允许或拒绝。前端按钮是否显示,只能改善使用体验,不能承担最终权限判断。
访问控制测试最常见的低效做法,是看到一个对象编号就直接改。这样做有两个问题:你不知道预期规则是什么,也不知道碰到的是不是别人的真实数据。专业一点的顺序应该反过来:先把业务规则写成矩阵,再准备刚好够用的测试账号和对象。
水平边界至少需要两个同角色账号。我们把它们叫作甲和乙,各自创建一条内容明显、没有真实信息的测试记录。垂直边界再准备一个经明确授权的高权限账号。若要测试多租户隔离,甲与乙还应分别处于两个专用测试租户;不要拿生产客户租户充当样本。
为每个账号单独保存会话,并在测试记录中写明它代表的角色、租户和组织关系。不要只靠浏览器窗口的位置来记。两个标签页切错会话,是授权测试里非常常见的假阳性来源。
先确认范围。写下允许测试的应用、接口版本、账号、对象类型、时间窗口、可执行动作与停止条件。
再准备身份。创建两个同角色账号用于水平验证,再准备一个范围内的高权限账号用于垂直对照。
接着准备数据。让甲和乙各自创建一个虚构对象,并记录对象标识、归属、当前状态和预期权限。
最后建立基线。先让每个账号完成本来允许的操作,确认接口正常、会话没有串用,再执行负向测试。
我们以团队文档系统为例。成员可以编辑自己的草稿;同团队主管可以读取成员已提交的文档,但不能替成员修改草稿;审计员可以读取归档版本,却不能编辑任何版本。把这些规则拆开后,矩阵可以先长这样:
矩阵中的“拒绝”用例和“允许”用例同样重要。若一条本应允许的基线请求也失败了,那么后续得到的拒绝不能证明权限有效,它可能只是接口坏了、对象状态不对,或者会话已经过期。
详情页做了授权,不代表同一对象的所有入口都安全。测试清单还应包括:
同一个动作如果有多个入口,应比较它们是否执行同一条授权规则。很多漏洞并不是系统完全没有权限设计,而是新接口、旧版本或批量入口没有接入原来的检查。

直接对象引用指的是客户端把某个标识交给服务器,服务器据此找到对象。标识可能在路径、查询参数、请求体、请求头、文件名或 GraphQL 变量中。它可以是数字,也可以是 UUID、订单号、用户名、存储键或一段看起来随机的字符串。
真正的问题不是“这个编号能不能猜到”,而是服务器找到对象后,有没有验证当前主体对这个具体对象的权限。把连续数字换成 UUID 能减少误撞和猜测,却不能代替授权。标识仍可能从列表、日志、通知、分享记录或前端响应中出现。一旦拿到标识,缺失的对象级检查还是缺失。
在 API 场景里,这类问题通常称为对象级授权失效,也常见 BOLA 这个简称。它和 IDOR 关注的是同一条核心边界:客户端能够指定对象,而服务器没有正确约束当前主体能操作的对象集合。
我们不需要遍历编号。两个专用账号、两条虚构记录和一次交叉请求,已经足够验证边界。
假设成员甲创建了测试笔记 note-a,成员乙创建了 note-b。先用甲的会话读取 note-a,再用乙的会话读取 note-b,确认两条允许基线都正常。随后保持乙的会话不变,只把目标改成甲预先创建的 note-a:
GET /api/lab-notes/note-a
Cookie: session=乙的测试会话安全结果应该是拒绝,或者返回一个不暴露对象是否存在的统一结果。若响应交出了甲的正文、附件地址、敏感元数据或可用操作令牌,就出现了水平读取越权迹象。若书面范围允许验证写入,只修改一个可恢复的专用测试字段,再由甲账号核对;没有明确许可时,不发送删除、分享、付款或通知类请求。
不要为了“确认影响范围”而连续尝试相邻编号。一次受控交叉请求已经能证明对象级授权失效。更大的影响范围应由系统所有者通过代码审查、日志和数据关系评估,而不是由测试者继续读取更多对象。
200 不一定表示越权成功,403 也不一定表示系统没有产生副作用。判断一条对象级用例时,要同时看:
有些系统选择用 404 隐藏资源是否存在,有些系统使用 403 明确表示权限不足。两种策略都可以成立,重点是内部真的拒绝了访问,对外语义保持一致,而且拒绝路径没有夹带敏感数据或部分写入。
能读取一个对象,不等于能读取它的全部字段;能编辑标题,也不等于能修改 ownerId、tenantId、role 或审批状态。批量赋值、字段过滤不足和对象级权限失效可能叠在一起。
同理,附件、评论、历史版本和导出文件应被视为独立资源。主对象的页面被保护了,但附件使用可长期访问的静态地址,仍然可能绕开权限判断。对象关系必须延伸到这些子资源,而不是只保护最显眼的详情接口。

垂直越权关注的是功能边界。普通成员的界面里没有“停用账号”按钮,看起来很合理,但浏览器界面不可信。真正的问题是:普通成员直接请求相应服务端接口时,服务器会不会再次检查权限。
前端可以根据权限隐藏或禁用按钮,避免用户点到不能用的功能;它不能决定最终授权。客户端提交的 role、isAdmin、菜单编号、隐藏表单字段也不能成为可信依据。角色和权限要从服务端认可的会话与权限数据中取得。
选择无破坏性的管理动作,例如读取测试租户中的配置预览。用授权的管理员测试账号发起一次正常请求,记录方法、路径、必要参数和响应特征。然后切换到普通测试账号,先调用“当前身份”接口确认角色确实较低,再对同一个只读目标做一次负向验证。
如果管理功能只有写入形式,应优先使用预演模式、测试租户或可回滚的虚构对象。付款、发信、封禁、密钥轮换和账号删除不适合靠触发真实副作用来证明问题。权限决策日志、测试桩或代码路径能够证明时,就不必执行最后一步。
垂直边界常从接口差异里漏出去:
还有一种容易忽略的情况:角色刚被降低或撤销,但旧会话、旧缓存或已签发的操作链接仍保留原权限。授权测试不能只看“新登录以后是什么角色”,还要验证权限变更怎样传播到已经存在的会话和异步流程。

有些动作从角色和对象归属看都合理,却在当前业务状态下不应该发生。采购审批、退款、内容发布、账号恢复都属于这种情况。服务器若只判断“这是主管”,却不判断申请是否待复核、是否由本人发起、前置审核是否完成,就可能允许非法状态迁移。
上下文授权不是普通的表单格式校验。它决定主体此刻有没有权推动业务对象进入另一个状态,因此必须和角色、对象关系一起在服务端判断。
假设一条申请会经过草稿、待复核、已批准和已归档四个状态:
测试每条迁移时,至少准备一个正向和一个反向样例。正向样例确认流程本身可用;反向样例只改变一个条件,例如身份、组织关系或当前状态。这样才能知道究竟是哪条规则起了作用。
前端向导会按顺序显示步骤,但请求可以脱离界面直接发送。测试要确认最终提交端点是否自己检查前置状态,而不是相信客户端说“前一步已经完成”。已使用的一次性批准操作也不应被重复提交。
并发请求会带来另一类问题:两次操作都在旧状态下通过检查,然后分别写入互相冲突的结果。修复时,授权所依赖的状态检查与状态更新应放在能够保证一致性的事务或条件更新中。否则“先检查、后修改”之间的时间窗口,会让正确的规则在并发下失效。
职责分离也属于上下文规则。提交人与批准人不能是同一个主体;高风险动作可能要求两个不同角色分别确认。这些条件如果只写在操作手册里,没有进入服务端策略和状态迁移代码,就很容易被直接接口调用绕过。

访问控制修复不能停在“把这个接口加个角色判断”。一个系统有列表、详情、附件、批量操作、导出任务和后台消费者,散落在各处的 if 很难长期保持一致。更稳妥的设计,是把策略判断放在可信的服务端边界,并让每个资源访问入口都使用它。
默认拒绝意味着:只有明确规则允许时才放行。角色未知、对象关系查不到、租户缺失、策略服务异常或上下文不完整时,都应该拒绝,而不是为了“保证业务可用”悄悄转成允许。
每次请求都要判断权限。页面入口检查过,不代表后续 API 可以省略;第一阶段验证通过,不代表最终提交可以相信客户端携带的旧结果;链接签发时有效,也不代表权限撤销后永远有效。
一个服务端判断可以写成这样的伪代码:
actor = session.requireAuthenticatedActor()
action = route.toBusinessAction()
object = repository.findInTenant(request.objectId, actor.tenantId)
context = buildTrustedContext(actor, object, currentState, requestTime)
decision = policy.evaluate(actor, action, object, context)
if decision != ALLOW:
auditDenied(actor, action, object, decision.reason)
stopWithoutSideEffect()
performAction(object)这里最关键的不是具体框架,而是数据来源。主体、角色、租户、对象所有者和当前状态都来自服务端可信数据。客户端可以提出“我想操作哪个对象”,但不能自证“我是管理员”“这是我的对象”或“它已经审批通过”。
一种容易犯错的写法,是先按客户端给出的编号查出任意对象,再在业务代码的某个角落比较所有者。更稳妥的做法,是从当前主体的允许范围出发查询:
不稳妥:findById(objectId),然后相信请求体中的 ownerId
更稳妥:findAccessibleObject(actorId, tenantId, objectId, requiredAction)这样做不能取代复杂策略,但可以让“查到对象”本身就受租户与对象范围约束,减少后续忘记检查的机会。对于管理员、共享对象或代理操作,再由集中策略处理额外关系。
缓存会把原本正确的授权变成跨用户数据泄露。若高权限用户访问对象后,响应只按路径缓存,低权限用户请求同一路径就可能拿到高权限版本。
处理这类缓存时,要区分公共内容与私有内容。私有响应不应进入共享缓存;确实需要缓存授权结果时,缓存键至少要包含会影响决策的主体、租户、资源、动作和策略版本。角色撤销、对象转移、分享关系变化或策略更新后,还要及时失效相关缓存。
不要只验证缓存未命中的第一次请求。测试矩阵应加入顺序变化:先由高权限账号访问,再由低权限账号访问;再反过来执行。比较响应体、缓存标记和授权日志,确认缓存没有绕过服务端判断。
单条接口经常写得很谨慎,批量接口却只在入口检查一次“用户能否批量编辑”,然后对请求中的全部对象直接执行。这会把功能级授权错当成对象级授权。
批量操作应该对每个对象应用同一套主体、动作、资源与上下文规则,并明确失败策略:要么全部通过才执行,要么返回逐项结果,但不能静默跳过后让调用方误判。若操作要求原子性,应在执行前完成整批授权,并在事务中再次确认关键状态没有变化。
测试批量接口时,只使用两个预建对象:一个属于当前账号,一个属于另一个测试账号。不要提交长编号列表。一个“允许对象 + 拒绝对象”的最小组合已经足够看清它是逐项校验、全部放行,还是出现部分副作用。
导出、转码、通知和审批常由后台任务完成。前台创建任务时做过权限检查,任务真正执行时对象可能已经转移、权限可能已经撤销。系统要明确采用哪种语义:按任务创建时的授权快照执行,还是执行时重新判断。高风险动作通常需要重新确认当前权限与状态。
任务消息只携带最少、可验证的身份与授权上下文。后台消费者不能因为消息来自内部队列,就默认调用者拥有所有对象权限。任务结果、下载文件和状态查询也要继续受对象级授权保护。
授权规则一多,靠记忆测试一定会漏。把每条用例统一写成下面的形式,会更容易复查和自动化:
主体 × 对象关系 × 动作 × 状态 × 入口变体 × 预期结果例如:
成员乙 × 甲拥有的草稿 × 读取 × 未分享 × 详情接口 × 拒绝
成员甲 × 甲拥有的草稿 × 编辑 × 草稿 × 单条接口 × 允许
主管甲 × 甲自己提交的申请 × 批准 × 待复核 × 最终确认接口 × 拒绝
成员甲 × 自有对象与乙对象混合 × 批量编辑 × 草稿 × 批量接口 × 整批拒绝权限矩阵不是一次性表格。新增角色、接口、对象关系或流程状态时,应同步添加用例。共同规则可以由自动化集成测试覆盖,高风险端点和定制规则再单独验证。这样,访问控制不再依赖某次上线前有人“想起来测一下”。
比较两个身份的响应时,不要只看文本是否相同。公共字段相同可能完全正常,错误页不同也未必表示越权。我们真正要对账的是:预期允许的数据与动作有没有被正确限制。
可以记录状态码、业务错误码、必要字段、对象版本、后台任务编号和授权理由码。令牌、Cookie、完整个人数据与无关响应字段不应进入证据。一次清楚的正常基线和一次最小负向请求,通常比一大包未整理的响应更能帮助修复。

访问控制不仅要拒绝请求,还要让防守方看见边界上发生了什么。有效的授权审计至少能回答:哪个主体,在什么时间,通过哪个会话与入口,对哪类对象尝试了什么动作,策略给出什么结果,理由是什么。
日志可以记录稳定的主体标识、角色、租户、动作、对象类型、脱敏对象标识、策略结果、理由码、请求关联标识与时间。不要记录会话令牌、完整请求体、完整响应体、密码或业务敏感字段。日志本身也需要限制访问和修改权限。
测试前应通知监控团队专用账号与时间窗口,让演练流量可识别,但不能因此关闭真实告警。修复后,用原来的最小复现用例复测,再检查同一授权组件覆盖的相似入口。若根因是某个批量路由漏接了策略,不能只补一个路径,还要检查其他批量端点与后台消费者。
一条可修复的访问控制发现,应写清:
不要用没有验证过的批量结果夸大影响。发现一条对象级越权后,可以说明相同模式可能影响同类对象,但范围应由代码、日志和数据模型继续确认。报告中只保留证明结论所需的最少虚构数据。
如果你面对一个新系统,不知道从哪里开始,可以按下面的顺序走:
这一节的核心可以记成一句话:每个请求都要由服务端判断,当前主体能否在当前上下文中,对当前资源执行当前动作。 权限矩阵告诉我们预期,最小单对象请求验证边界,缓存、批量接口和状态迁移则用来检查那些最容易被遗漏的执行路径。
授权解决的是“这个人能不能做这件事”。即使答案是允许,服务器仍要继续判断请求中的数据是否符合格式、范围和业务约束。下一节进入输入验证漏洞:我们会把注意力从“谁可以提交”移到“提交进来的值能不能被安全、准确地处理”。