越权防护设计评审指南
面向产品、设计、开发、测试的一份协作手册。讲清楚每个角色在防越权这件事上负责什么、在哪个环节介入、做到什么程度才算合格。
目录
- 从一次渗透测试说起
- 越权到底是什么
- 各角色在防越权中的分工
- [RBAC 权限模型](#RBAC 权限模型 "#4-rbac-%E6%9D%83%E9%99%90%E6%A8%A1%E5%9E%8B")
- 权限的三个层次
- 三道防线:设计评审、编码、测试
- 技术负责人评审清单
- 权限矩阵模板
- 角色继承的安全陷阱
- 怎么让这些要求真正落地
- 常见越权漏洞速查
1. 从一次渗透测试说起
假设你负责一个 B 端 SaaS 平台的研发。产品上线半年,功能越加越多,权限也越配越复杂。设计师画好了权限配置页面,前端做了按钮级隐藏,后端的登录和鉴权跑了半年也没出过问题。
直到安全团队做了一次渗透测试,结果是这样的:
- 系统里几十个接口没有任何权限保护,任何登录用户都能访问;
- 一个普通用户通过"创建角色"接口,给自己建了一个超级管理员角色,由此拿到了全部权限;
- 把别人的用户 ID 填进请求参数,就能看到对方的全部数据,包括联系方式、订单和私密信息;
- 这些问题有一个共同根因:只做了页面级的隐藏,后端接口没有做任何校验。
这些不是"某个接口写错了",而是整个系统的授权设计在一开始就漏掉了。如果能在设计评审阶段发现并纠正,基本不会留到上线之后。
本文的目标就是让产品、设计、开发、测试每个角色都清楚:在防越权这件事上,自己该做什么、什么时候做、做到什么标准。
2. 越权到底是什么
先说一个日常场景。公司大楼装了门禁系统,刷卡进门叫"认证",它证明你是公司的人。但你刷得开财务室的门吗?刷不开才是对的,这道判断叫"授权"。所谓越权,就是你拿着一张普通员工的门禁卡,刷开了财务室、服务器机房和总经理办公室。问题不在卡上,而在于门禁系统根本没有给每扇门设置准入规则。
再看一个更贴近开发的例子。你在电商后台查看自己的订单,URL 是 /order/1024。如果把 1024 改成 1025,就看到了别人的订单详情,里面有收件人姓名、电话和地址。这就是水平越权:你有权限看订单页面,但系统没有校验这条订单是不是你的。
越权通常分两类:
| 类型 | 含义 | 例子 |
|---|---|---|
| 垂直越权 | 低权限的人做了高权限的事 | 普通员工调用了"创建角色"接口,给自己建了一个超级管理员账号 |
| 水平越权 | 有权查看或修改某类资源,但操作了别人的 | 查看订单详情时把订单 ID 从 1024 改成 1025,看到了别人的订单和地址 |
这里有一条需要反复强调的原则:前端隐藏不等于安全。用户在浏览器里隐藏一个按钮只需要点一下"检查元素",构造一个 HTTP 请求也只需要在终端敲一行命令。后端才是最后一道防线,而这道防线必须在设计阶段就建好。
3. 各角色在防越权中的分工
防止越权不是安全团队一个人的事。从需求到上线,四个角色各有分工。
| 角色 | 定位 | 主要职责 | 产出 |
|---|---|---|---|
| 产品经理 | 定"谁能做什么" | 梳理所有角色和用户类型;定义每个角色的功能边界;明确敏感操作和数据字段 | 角色清单、功能范围表 |
| 交互 / UI 设计师 | 把权限画进矩阵 | 设计权限配置页面;绘制权限矩阵(角色 × 操作);处理无权限和 403 状态的体验 | 权限矩阵、权限配置原型 |
| 后端开发者 | 把矩阵变成代码校验 | 实现统一鉴权拦截器;为每个接口声明所需权限;编写对象归属校验 | 权限声明代码、校验逻辑 |
| 测试 / QA | 写"不该能做的事"的测试 | 基于矩阵编写负向测试用例;验证低权限角色访问高权限接口;验证用他人 ID 访问的 IDOR 场景 | 负向测试用例、自动化脚本 |
技术负责人或架构师需要单独拎出来说。这个角色定规矩、查矩阵、签评审,对授权设计负最终责任,具体包括:
- 项目启动时就确立"默认拒绝"原则:所有接口默认需要权限声明,缺失声明的由 CI 拦截;
- 设计评审中逐项核对权限矩阵,覆盖率不到 100% 不予通过;
- 对每个写接口和敏感接口,要求书面回答"防垂直越权方案"和"防水平越权方案";
- 签字确认评审产物(权限矩阵与威胁建模说明),对越权漏洞承担问责;
- 推动 CI 接入 SAST(静态扫描缺失权限声明的接口)和自动化负向测试。
各角色在三道防线中的介入时机如下:
| 防线 | 时机 | 参与人 | 产出 |
|---|---|---|---|
| 设计评审 | 需求评审后、写代码前 | 产品 + 设计 + 技术负责人 | 角色清单、权限矩阵、威胁建模说明 |
| 编码 | 开发阶段 | 后端开发 | 统一鉴权拦截器、每个接口的权限声明、归属校验逻辑 |
| 测试 | 提测后、上线前 | 测试 / QA + CI 自动化 | 负向测试用例、SAST 规则、CI 拦截策略 |
4. RBAC 权限模型
RBAC(基于角色的访问控制)用一句话就能说清楚:不给"人"直接配权限,而是给"角色"配权限,再把角色赋予人。这样人员变动时只需要调整角色,不必逐个修改权限。
举个例子。没有 RBAC 时,新来一个市场部员工,管理员要在系统里逐项勾选"能不能看报表、能不能发公告、能不能编辑内容",每来一个人就要重复一遍。有了角色之后,管理员只需要把"市场专员"这个角色赋给新人,而角色的权限是预先配好的。
RBAC 一般分四个层次,从简单到复杂:
| 层次 | 是什么 | 类比 |
|---|---|---|
| 基本模型 | 用户 → 角色 → 权限。一个人可以有多个角色,权限取并集 | 一个人既是"市场专员"又是"项目组长",就能同时看市场和项目的报表 |
| 用户组 / 权限组 | 把一群用户打包成组,或把一堆权限打包成组,便于批量操作 | "华东区全员"是一个用户组,直接给组赋角色,不用逐个加人 |
| 角色继承 | 上层角色自动获得下层的全部权限,并可额外追加 | "部门主管"自动拥有"组长"的全部权限,还能审批请假。但继承链一旦复杂,就没人说得清某个角色到底有多少权限 |
| 角色互斥 | 某些角色不能同时赋给同一个人 | "采购申请"和"采购审批"不能是同一人,避免既当运动员又当裁判 |
设计师在这个模型下需要关注四件事:
- 权限配置页面的交互,即角色、用户、权限三者的关系如何在界面上表达清楚;
- 权限拆分的最小单元,通常每个对象的增、删、改、查各自独立为一个权限单元;
- 无权限时的体验,用户通过 URL 直接访问无权限页面时要有明确提示,而不是空白页或报错页;
- 表达规范,用网格矩阵(角色为列、操作为行、交叉点标注权限关系)代替散文式描述。
技术负责人需要关注的是模型本身的风险点:
- 角色继承是安全高风险区。 继承意味着隐式授权累加,难以说清某个角色的最终权限。应对继承深度设硬上限,建议不超过 3 层,并配套可视化的审计工具。
- 禁止通配权限。 不允许任何业务角色使用"全部权限"通配符。想象一下,如果有人能创建一个叫"万能"的角色赋给自己,那在现实里就是一个越权漏洞。
- 互斥规则必须系统级强制。 不能只靠制度规定"采购申请和审批不能是同一人",而要在代码或配置层面禁止同时赋予这两个角色。
5. 权限的三个层次
一个产品里的"权限"其实分三层。只做第一层(页面隐藏),是绝大多数越权漏洞的根本原因。
| 层次 | 控制什么 | 典型手段 | 只做这一层的问题 |
|---|---|---|---|
| 页面权限 | 能不能看到这个页面 | 菜单入口显隐、路由拦截 | 用户可以直接输入 URL 或构造请求绕过 |
| 操作权限 | 能不能执行这个操作 | 后端接口独立校验权限 | 这是防越权的主战场。前端隐藏按钮,不代表后端安全 |
| 数据权限 | 能看到、操作哪些数据 | 归属校验、ABAC、关系模型、行级安全 | 最容易被忽略的一层,出事后果也最严重,必须在设计期建模 |
数据权限内部还会再细分三种形式:
| 形式 | 含义 | 例子 |
|---|---|---|
| 数据范围 | 能看哪些"行" | 查看订单列表时,你的角色只能看本团队的订单,看不到全公司的 |
| 数据边界 | 能操作多少"条" | 组长可以添加人员,但每月上限 20 个 |
| 数据字段 | 能看到哪些"列" | 查看人员详情时,HR 能看到薪资字段,其他人只能看到姓名和邮箱 |
RBAC 的天然边界
RBAC 的设计模型是"角色 → 操作"的映射。它能回答"管理员能不能删除用户",因为这个问题只需要知道角色和操作的对应关系。但它回答不了"用户 A 能不能查看订单 #1024",因为这个问题需要知道 A 和 #1024 之间的关系,比如这条订单是不是 A 的、A 和订单所属人是否同团队,而 RBAC 模型里根本没有"资源归属关系"这个概念。
换句话说,RBAC 管的是"操作",也就是接口层;管不了"数据",也就是对象层。
| RBAC 能管 | RBAC 管不了 | 需要什么来补 |
|---|---|---|
| 管理员能不能调"创建角色"接口 | 管理员能不能看别人名下的订单 | 对象归属关系(owner / team / tenant) |
| 普通用户能不能调"删除用户"接口 | 普通用户能不能看到薪资字段 | 字段级属性规则(ABAC) |
| 游客能不能调"导出数据"接口 | 组长能看哪些下属的绩效数据 | 组织关系图(ReBAC,例如 Google Zanzibar) |
所以数据层的授权需要额外建模,常见做法有四种:
- 归属校验 :最简单的一层,查完数据后加一句
if (resource.ownerId !== currentUser.id) return 403; - ABAC(属性级):定义规则如"用户部门等于数据所属部门",用属性而非角色做判断;
- ReBAC(关系级):维护一张关系表,记录"谁对什么资源有什么权限",Google Drive 的共享权限就是这个模型;
- 数据库行级安全(RLS) :在 SQL 层直接加
WHERE tenant_id = currentTenantId(),应用层无法绕过。
对技术负责人的要求是:设计评审时必须明确这一层用 RBAC 管操作、那一层用什么模型管数据,不能笼统地说一句"我们用了 RBAC"就认为权限问题全都解决了。
需要特别提醒的是,大多数团队只做到第一层。产品说"这个页面只有管理员能看到",设计师把左侧菜单入口藏了,开发在路由上加了个判断,就算做完了,后台接口却完全没有校验,任何人只要能构造请求就能调通。权限三层必须层层独立校验,任何一层都不能依赖上一层来保证安全。
"ID 够复杂,还需要做归属校验吗"
这是研发和测试之间最常见的分歧。研发的说法通常是:这个订单 ID 是 UUID v4,128 位随机数,暴力枚举到宇宙毁灭也猜不出来,所以直接用请求里的 ID 查就行,不需要校验是不是当前用户的。
但从安全视角看,"猜不到"不等于"拿不到"。ID 的泄露途径远比想象中多:
| 泄露途径 | 怎么泄露的 | 真实场景 |
|---|---|---|
| 浏览器历史 / 书签 | 用户分享了浏览器或屏幕截图 | 同事借电脑"帮我查个订单",地址栏里的 /order/a3f2b91c-... 一览无余 |
| Referer 头 | 页面里有外链或第三方资源,浏览器自动在 Referer 里带上完整 URL | 用户在订单详情页点了外部帮助链接,订单 ID 出现在那家网站的访问日志里 |
| 日志与监控 | Nginx 日志、APM 全链路追踪、错误上报系统都可能记录完整 URL | 运维查日志定位问题,顺带看到了所有请求的完整路径和 ID |
| 信息泄露接口 | 其他接口返回了不该返回的数据 | 用户列表接口忘了过滤,响应里把所有人的订单 ID 一起返回了 |
| 内部威胁 | 内部人员恶意利用 | 客服能查到自己权限范围外的订单详情,用一个已知 ID 就能遍历 |
| 时序与批量枚举 | ID 存在规律,即使 UUID,时间戳部分也可预测范围 | UUID v7 前半段就是毫秒级时间戳,同一时间段创建的记录前 48 位完全相同 |
用 ID 复杂度替代归属校验,本质是"安全靠模糊"(Security through Obscurity),在安全领域是公认不可靠的策略。ID 不可猜测降低的是攻击的便利性,并没有消除漏洞的存在性。一旦 ID 通过上面任一渠道泄露,攻击者就能畅通无阻。
正确做法始终是:不管 ID 多复杂,服务端都要校验当前用户是否有权访问这个资源。
这里有个沟通技巧可以分享给测试同学。当研发说"ID 猜不到所以不用校验"时,不必去争论能不能猜到,直接问他:"如果这个 ID 因为日志、截图或 Referer 泄露了,你能保证数据不外泄吗?"这样就能把讨论从概率拉回到"到底存不存在漏洞"。
6. 三道防线:设计评审、编码、测试
核心思路只有一句:防线建得越早,成本越低。不要等上线后靠渗透测试来发现越权漏洞,在设计阶段就把它堵住。
6.1 防线一:设计评审
这一步不写一行代码,却决定了后续所有工作的方向,需要产品、设计、技术负责人三方共同参与。
产品要给出三样东西:
- 角色清单。 系统里有哪些角色,比如管理员、部门主管、普通员工、游客,每个角色大致能做什么;
- 敏感数据和操作清单。 哪些字段敏感(薪资、身份证号、联系方式),哪些操作敏感(创建角色、删除数据、导出);
- 多租户场景说明。 系统是否服务多个客户或租户,数据之间是否需要完全隔离。
设计要给出两样东西:
- 权限矩阵(角色 × 操作)。 用表格表达每个角色对每个功能模块是"能看""能增删改"还是"不能看"。这是后续所有工作的基础,模板见[第 8 节](#第 8 节 "#8-%E6%9D%83%E9%99%90%E7%9F%A9%E9%98%B5%E6%A8%A1%E6%9D%BF")。
- 权限配置页面的交互设计。 谁来配权限、怎么配,如果支持自定义角色,配置界面长什么样。
技术负责人要确认四件事:
- "默认拒绝"原则已确立。 所有接口的默认行为是"没有声明权限就返回 403",这条原则要在第一行代码写出来之前定下;
- 统一鉴权方案已定。 用一个全局拦截器处理所有请求的权限校验,不允许在各接口里散落
if (role === 'admin')式的判断; - 敏感接口已做威胁建模。 每个写接口和敏感接口的设计文档里,必须用两句话回答:怎么防止低权限用户调用,怎么防止用户访问别人的数据;
- 多租户隔离方案已定。 如果是多租户系统,要确定租户 ID 从哪里来,它必须从登录会话推导,绝不能从客户端请求参数里取。
6.2 防线二:编码
后端的授权实现说穿了就四件事。
第一件,建一个统一的"门卫"。 在框架的请求处理流程中找一个所有请求都会经过的位置,放一个全局拦截器:
js
// 伪代码:全局权限拦截器
function permissionInterceptor(request, next) {
// 1. 从请求中拿到当前用户信息(通常来自登录 token)
const user = getCurrentUser(request)
// 2. 拿到这个方法要求的权限列表(来自代码中的权限声明)
const requiredPermission = getRequiredPermission(request)
// 3. 核心规则:如果这个方法没有声明任何权限要求,直接拒绝
if (requiredPermission 为空) {
return 403("此接口未配置权限声明")
}
// 4. 检查用户是否拥有所需权限
if (!user.权限列表.contains(requiredPermission)) {
return 403("无权限")
}
// 5. 通过,继续处理请求
return next(request)
}
这里有一个很常见也很致命的错误:有些项目把第 3 步写反了,写成 if (requiredPermission 为空) return next(request),也就是"没有声明权限就放行"。这正是越权漏洞的根源,因为没有声明权限的接口,恰好是最需要被拦住的那批。
第二件,每个接口明确声明"谁才能调我"。 在每一个接口方法上加一个权限声明,不同语言和框架的写法不同,但逻辑一致:
js
// 这是"查看用户列表"的接口,声明需要 "users:read" 权限
@PermissionRequired("users:read")
function getUserList() {
return 查询用户列表
}
// 这是"删除用户"的接口,声明需要 "users:delete" 权限
@PermissionRequired("users:delete")
function deleteUser(userId) {
...
}
权限命名建议统一用"模块:操作"的格式,比如 users:read、orders:export、settings:admin。这种格式清晰,也方便后续的矩阵映射和 SAST 扫描。
第三件,操作数据前校验"这条数据是不是你的"。 这是防水平越权的关键,不要直接根据请求参数里的 ID 查库后原样返回:
js
// 错误写法------水平越权漏洞
function getOrderDetail(orderId) {
return 数据库.orders.findById(orderId) // 任何人传任何 ID 都能看到
}
// 正确写法------加上归属校验
function getOrderDetail(orderId, currentUser) {
const order = 数据库.orders.findById(orderId)
if (order.ownerId !== currentUser.id 且 currentUser.role !== 'admin') {
return 403("无权访问此订单")
}
return order
}
更推荐的做法是把归属校验抽成一个公共工具函数,团队统一调用:
js
// 公共工具函数
function getResourceIfOwner(resourceId, resourceType, currentUser) {
const resource = 数据库[resourceType].findById(resourceId)
if (resource.ownerId !== currentUser.id) {
throw new ForbiddenError("无权访问")
}
return resource
}
// 使用时只需一行
@PermissionRequired("orders:read")
function getOrderDetail(orderId, currentUser) {
return getResourceIfOwner(orderId, "orders", currentUser)
}
第四件,别让用户偷偷改自己的权限。 如果接口接收用户提交的数据来更新数据库,必须用白名单控制哪些字段允许更新,否则用户可以在请求体里塞一个 {"role": "admin"} 把自己的权限提上去:
js
// 错误写法------批量赋值漏洞
function updateProfile(userId, requestBody) {
数据库.users.update(userId, requestBody) // 用户可以在 body 里传 role: "admin"
}
// 正确写法------白名单控制
function updateProfile(userId, requestBody) {
// 只允许更新这三个字段,其他字段忽略
const allowedFields = ["nickname", "avatar", "phone"]
const safeData = 从 requestBody 中只提取 allowedFields
数据库.users.update(userId, safeData)
}
6.3 防线三:测试
常规测试只验证"正常流程能走通"。防越权需要的是负向测试,专门验证"不该能做的事确实做不了"。
| 优先级 | 测试项 | 做法 |
|---|---|---|
| 必须做 | 角色 × 接口矩阵负向测试 | 用权限矩阵中每个"无权限"的角色去访问对应接口,预期结果是 403。返回 200 或 500 就是漏洞 |
| 必须做 | 水平越权(IDOR)测试 | 以普通用户身份登录,用别人的资源 ID 去访问,预期结果是 403 或"无数据" |
| 推荐做 | SAST 静态扫描 | 扫描代码中的两类模式:哪个接口方法没有权限声明,哪里用请求参数直接查库却缺少归属校验。扫描结果接入 CI |
| 推荐做 | CI 自动化拦截 | 把 SAST 和负向测试集成到流水线。新增接口缺少权限声明,或者负向测试失败,合并请求直接拦截 |
负向测试用例如下:
js
// 负向测试:普通用户尝试访问管理员接口
test("普通用户不能访问创建角色接口", async () => {
// 1. 用普通用户登录,拿到 token
const userToken = await login("normal_user", "password")
// 2. 用这个 token 去调管理员才能用的接口
const response = await httpPost("/api/roles", {
headers: { Authorization: userToken },
body: { name: "偷偷建的角色", permissions: ["admin"] }
})
// 3. 必须是 403
assert.equal(response.status, 403)
})
// 水平越权测试:用户 A 访问用户 B 的数据
test("用户不能访问他人的订单详情", async () => {
const userAToken = await login("user_a", "password")
// user_b 的订单 ID 是 9999
const response = await httpGet("/api/orders/9999", {
headers: { Authorization: userAToken }
})
// 必须是 403 或 404
assert.isTrue(response.status === 403 || response.status === 404)
})
7. 技术负责人评审清单
设计评审时直接拿这份清单逐项核对。任何一条答案为"否",都是一个上线后的越权风险点,必须解决后才允许通过评审。
-
是否所有请求都经过同一个全局拦截器做鉴权?
不能在各接口里散落
if (role === 'admin')判断,必须统一入口。 -
缺权限声明的接口,默认行为是拒绝还是放行? 必须是拒绝。新增接口如果忘记声明权限,不应该悄无声息地对外开放。
-
有没有《接口 × 角色权限矩阵》?覆盖率是否 100%? 每一行对应一个接口加上所需权限和数据范围限制。缺一个接口就是缺一个风险点。
-
每个涉及写、改、删的敏感接口,有没有书面回答两个问题? 一是怎么防止低权限用户调用,二是怎么防止用户操作别人的数据。
-
用资源 ID 查询数据前,有没有校验当前用户是不是这个资源的 owner? 不能
findById之后直接返回,必须有归属校验,最好用公共工具函数统一处理。 -
多租户场景下,租户 ID 是否只从登录会话推导、绝不从请求参数取? 如果客户端可以传入 tenant_id 决定数据范围,就是天然的数据越权。
-
有没有"低权限角色访问高权限接口"的自动化负向测试? 权限矩阵中每个标"无权限"的单元格,都要有对应的自动化测试用例。
-
有没有"用他人 ID 访问"的水平越权自动化用例? 至少覆盖所有涉及资源 ID 参数的接口,比如订单、用户、文件、消息。
-
CI 流水线能不能自动拦截缺权限声明的接口? 用 SAST 静态扫描检查,新增接口缺声明就让 CI 标红,不允许合并。
-
角色继承链是否可追溯、可审计? 能一眼看出某个角色的有效权限来自哪些继承,并且禁止"全部权限"通配符。
-
互斥角色是否在系统层面强制,而不是靠制度约束? 比如"采购申请"和"采购审批"不能赋给同一个人,这必须在代码或配置层面强制。
-
接收用户输入的更新接口是否用了白名单控制允许的字段?
role、isAdmin、tenantId这类敏感字段绝不能从请求体直接更新。 -
是否因为"ID 足够复杂"而省略了归属校验? ID 复杂只能降低被猜中的概率,不能防止它通过日志、Referer、截图或信息泄露接口被拿到之后的越权访问。不论 ID 多复杂,归属校验都不能省。
8. 权限矩阵模板
下面是一张可以直接套用的三维权限矩阵模板:行是每个接口,列是每个角色,单元格表示权限关系和数据范围限制。这里以"人员管理"模块为例,实际使用时替换为你们真实的接口与角色。
| 接口(操作) | 方法 | 路径 | 游客 | 普通成员 | 组长 | 管理员 | 数据范围限制 |
|---|---|---|---|---|---|---|---|
| 查看人员列表 | GET | /api/users | 无 | 本团队 | 本组 | 全部 | 按角色过滤数据行 |
| 查看人员详情 | GET | /api/users/:id | 无 | 归属校验 | 本组 | 全部 | 需 owner 或同团队 |
| 添加人员 | POST | /api/users | 无 | 无 | 每月 ≤20 | 无限制 | 组长有数量上限 |
| 编辑人员 | PUT | /api/users/:id | 无 | 仅自己 | 本组 | 全部 | 普通成员只能改自己 |
| 删除人员 | DELETE | /api/users/:id | 无 | 无 | 无 | 全部 | 仅管理员 |
| 查看薪资字段 | - | (同上详情接口) | 无 | 无 | 无 | 仅 HR | 字段级权限 |
填写时遵循以下规范:
- 覆盖所有接口。 不只是 HTTP 接口,WebSocket 和内部 RPC 调用也要列出。
- 单元格三选一。 有权限、无权限、有权限但有限制,选第三种时必须注明具体限制内容。
- 数据范围列必填。 即便是"全部"也要显式写出来,以证明确实考虑过。
- 字段级权限单独标注。 例如"薪资字段仅 HR 角色可见",在数据范围限制列写明。
- 评审时三方签字。 产品确认业务正确性,设计确认交互可落地,技术负责人确认安全可行。
9. 角色继承的安全陷阱
在 RBAC 里,角色继承很像公司的汇报线:"部门主管"自动拥有"组长"的全部权力,还能审批请假。这在管理上很方便,但如果汇报线有五层,从 CEO 到 VP、总监、经理、组长,就没有人能一眼说清一个"经理"到底有哪些权力,因为他的权力是组长、经理自身以及以上各层权限的隐式累加。
继承之所以是安全高风险区,原因有三点:
- 权限来源不透明。 无法一眼看出某个角色的权限是从哪个父角色继承来的。
- 变更影响不可预测。 改了一个父角色的权限,不知道会波及多少子角色的用户。
- 通配权限极易通过继承扩散。 如果某个父角色带有"全部权限",所有子角色都会自动获得,不管设计意图是什么。
对继承关系需要有强制要求:
| 项目 | 标准 | 不满足时的处理 |
|---|---|---|
| 继承深度 | ≤ 3 层 | 4 层及以上,CI 禁止创建,需重构为扁平角色或用户组 |
| 通配权限 | 禁止用于业务角色 | CI 拦截。确需全权限的,必须走安全评审并由 VP 签字 |
| 继承链可视化 | 系统内可查看 | 上线前必须提供"角色 → 有效权限"的展开树视图 |
| 继承变更审批 | 任何变更需审批 | 新增、删除、调整继承关系需提交变更对比图和受影响用户清单,技术负责人签字 |
| 审计日志 | ≥ 180 天留存 | 记录谁、在什么时间、修改了哪个继承关系、影响了哪些用户 |
10. 怎么让这些要求真正落地
规范写得再好,没人执行等于零。以下是让规范真正落地的四个机制。
10.1 写入"完成定义"
把越权防护要求写进团队的 Definition of Done。以下任一条不满足,该功能都不算"完成":
- 接口在权限矩阵中有对应的行,矩阵覆盖率检查通过;
- 接口代码里有明确的权限声明,SAST 扫描通过;
- 写接口和敏感接口有归属校验逻辑,Code Review 通过;
- 有对应的负向测试用例,测试执行通过。
10.2 评审签字制
技术负责人对授权设计负最终责任,这个责任不该由安全团队来背。评审产物(权限矩阵和威胁建模说明)需三方签字后方可进入开发,评审记录归档备查。
10.3 可度量的指标
| 指标 | 目标值 | 怎么算 |
|---|---|---|
| 越权漏洞数(上线后发现) | 0 | 渗透测试或安全审计中新发现的越权类漏洞 |
| 缺权限声明的接口数 | 0 | SAST 扫描:有路由但无权限声明的接口方法数 |
| 权限矩阵覆盖率 | 100% | 矩阵中已列出的接口数 ÷ 系统总接口数 |
| 负向测试覆盖率 | ≥ 90% | 矩阵中"无权限"单元格有对应自动化测试的比例 |
10.4 持续运营
- 每季度全量审计。 权限矩阵、继承关系、负向测试全部过一遍,确保没有随着迭代退化。
- 变更触发检查。 新增接口、角色或继承关系时,自动触发权限矩阵更新和 SAST 扫描。
- 每月度量复盘。 越权相关指标纳入团队月度复盘。
- 上线前渗透测试。 由独立团队做一次越权专项渗透,作为最后一道验证。
11. 常见越权漏洞速查
下面这 11 种模式在实际系统中最常出现。示例都用伪代码,不依赖任何具体框架,方便跨语言理解。
接口级越权(垂直越权)
1. 全局拦截器"默认放行"
text
// 致命错误:没有声明权限就放行
if (requiredPermission 为空) {
return next(request) // 应该 return 403
}
修复:改为"没有声明权限就返回 403"。
2. 只在前端藏了按钮和菜单
text
// 前端:admin 才显示按钮
<button v-if="role == 'admin'">创建角色</button>
// 后端:完全没做权限校验
function createRole(request) {
return 数据库.roles.insert(request.body) // 任何人直接调接口都能创建
}
修复:后端接口必须独立校验权限,不能依赖前端隐藏。
3. 鉴权逻辑散落在各接口里
text
function getReportA() {
if (user.role !== 'admin') return 403 // 散落在这里
}
function getReportB() {
// 忘了写鉴权,直接可调
}
修复:统一到全局拦截器加声明式权限。
4. 通配权限被业务角色滥用
text
// 某个接口允许创建自定义角色
// 用户可以传:{ "name": "万能角色", "permissions": "*" }
数据库.roles.insert({ name: request.body.name, permissions: request.body.permissions })
修复:系统层面禁止业务角色使用通配符,创建角色时校验权限值。
数据级越权(水平越权 / IDOR)
5. 直接用请求参数查数据库
text
function getOrderDetail(orderId) {
return 数据库.orders.findById(orderId) // 任何人传任意 ID
}
修复:加上 if (order.ownerId !== currentUser.id) return 403。
6. 租户 ID 由客户端传入
text
function getTeamData(request) {
return 数据库.teams.find({ tenantId: request.body.tenantId }) // 客户端传什么查什么
}
修复:tenant_id 从登录 token 推导,不信任客户端。
7. 批量赋值:用户把自己的角色改成管理员
text
function updateProfile(userId, requestBody) {
数据库.users.update(userId, requestBody)
// 请求体里可以传 { "role": "admin" }
}
修复:白名单控制可更新字段,role、isAdmin、tenantId 绝不允许从请求体更新。
8. 列表接口无数据范围过滤
text
function getUserList() {
return 数据库.users.findAll() // 任何人看到全公司用户
}
修复:根据角色注入查询条件,比如 WHERE team_id = currentUser.team_id。
9. 敏感字段未做字段级过滤
text
function getUserDetail(userId) {
return 数据库.users.findById(userId) // 所有字段全返回,包括薪资、身份证号
}
修复:序列化输出时根据角色过滤字段。
10. 角色继承链不可审计
text
角色A 继承 角色B 继承 角色C 继承 角色D
// 没有人说得清角色A到底有哪些权限
// 因为权限 = D的全部 + C的全部 + B的全部 + A自己的
修复:继承深度不超过 3 层,配套"角色 → 权限"展开树,变更需审批。
11. "ID 够复杂所以不校验归属"
这是测试和研发之间最常见的分歧。研发认为用了 UUID 就不可能被猜到,安全视角则认为猜不到不等于拿不到。
text
// 研发的写法:ID 是 UUID v4,觉得足够安全
function getOrderDetail(orderId) { // orderId = "a3f2b91c-7d4e-..."
return 数据库.orders.findById(orderId) // 任何人传任何 UUID 都能查
}
// 为什么还是漏洞?ID 可能通过以下途径泄露:
// 1. 用户截图分享 → 地址栏里的 /order/a3f2b91c-... 被看到
// 2. 点了外部链接 → Referer 头里带了完整 URL
// 3. Nginx / APM 日志 → 运维查日志看到了所有 ID
// 4. 另一个接口泄露了订单 ID 列表 → 拿到一批 ID 就能遍历
// 5. UUID v7 前半段是时间戳 → 同时创建的记录前半段一样
修复:不管 ID 用什么格式,永远加上归属校验 if (order.ownerId !== currentUser.id) return 403。ID 复杂度是纵深防御的一层,不是替代归属校验的理由。
结语
越权不是"个别人写错了一个接口",而是整个团队没有把授权设计当成一件系统性的事来做。它需要产品把角色理清楚、设计把矩阵画出来、开发把校验写进代码、测试把"不该能做的事"测完,而且这几步必须在设计阶段就启动,不能留到上线后靠渗透测试兜底。
最后浓缩成三句话:
- 每道门都要有锁。 页面、接口、数据三层独立校验,任何一层都不能依赖上一层。
- 默认不信任。 没声明权限就拒绝,没校验归属就禁止,宁可多拦不能漏放。
- 防线要左移。 设计评审定规矩,编码落地守规矩,测试自动化查规矩。上线后才发现,成本要翻很多倍。