【信息安全】越权防护设计评审指南

越权防护设计评审指南

面向产品、设计、开发、测试的一份协作手册。讲清楚每个角色在防越权这件事上负责什么、在哪个环节介入、做到什么程度才算合格。

目录

  1. 从一次渗透测试说起
  2. 越权到底是什么
  3. 各角色在防越权中的分工
  4. [RBAC 权限模型](#RBAC 权限模型 "#4-rbac-%E6%9D%83%E9%99%90%E6%A8%A1%E5%9E%8B")
  5. 权限的三个层次
  6. 三道防线:设计评审、编码、测试
  7. 技术负责人评审清单
  8. 权限矩阵模板
  9. 角色继承的安全陷阱
  10. 怎么让这些要求真正落地
  11. 常见越权漏洞速查

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. 技术负责人评审清单

设计评审时直接拿这份清单逐项核对。任何一条答案为"否",都是一个上线后的越权风险点,必须解决后才允许通过评审。

  1. 是否所有请求都经过同一个全局拦截器做鉴权?

    不能在各接口里散落 if (role === 'admin') 判断,必须统一入口。

  2. 缺权限声明的接口,默认行为是拒绝还是放行? 必须是拒绝。新增接口如果忘记声明权限,不应该悄无声息地对外开放。

  3. 有没有《接口 × 角色权限矩阵》?覆盖率是否 100%? 每一行对应一个接口加上所需权限和数据范围限制。缺一个接口就是缺一个风险点。

  4. 每个涉及写、改、删的敏感接口,有没有书面回答两个问题? 一是怎么防止低权限用户调用,二是怎么防止用户操作别人的数据。

  5. 用资源 ID 查询数据前,有没有校验当前用户是不是这个资源的 owner? 不能 findById 之后直接返回,必须有归属校验,最好用公共工具函数统一处理。

  6. 多租户场景下,租户 ID 是否只从登录会话推导、绝不从请求参数取? 如果客户端可以传入 tenant_id 决定数据范围,就是天然的数据越权。

  7. 有没有"低权限角色访问高权限接口"的自动化负向测试? 权限矩阵中每个标"无权限"的单元格,都要有对应的自动化测试用例。

  8. 有没有"用他人 ID 访问"的水平越权自动化用例? 至少覆盖所有涉及资源 ID 参数的接口,比如订单、用户、文件、消息。

  9. CI 流水线能不能自动拦截缺权限声明的接口? 用 SAST 静态扫描检查,新增接口缺声明就让 CI 标红,不允许合并。

  10. 角色继承链是否可追溯、可审计? 能一眼看出某个角色的有效权限来自哪些继承,并且禁止"全部权限"通配符。

  11. 互斥角色是否在系统层面强制,而不是靠制度约束? 比如"采购申请"和"采购审批"不能赋给同一个人,这必须在代码或配置层面强制。

  12. 接收用户输入的更新接口是否用了白名单控制允许的字段? role、isAdmin、tenantId 这类敏感字段绝不能从请求体直接更新。

  13. 是否因为"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 字段级权限

填写时遵循以下规范:

  1. 覆盖所有接口。 不只是 HTTP 接口,WebSocket 和内部 RPC 调用也要列出。
  2. 单元格三选一。 有权限、无权限、有权限但有限制,选第三种时必须注明具体限制内容。
  3. 数据范围列必填。 即便是"全部"也要显式写出来,以证明确实考虑过。
  4. 字段级权限单独标注。 例如"薪资字段仅 HR 角色可见",在数据范围限制列写明。
  5. 评审时三方签字。 产品确认业务正确性,设计确认交互可落地,技术负责人确认安全可行。

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 复杂度是纵深防御的一层,不是替代归属校验的理由。

结语

越权不是"个别人写错了一个接口",而是整个团队没有把授权设计当成一件系统性的事来做。它需要产品把角色理清楚、设计把矩阵画出来、开发把校验写进代码、测试把"不该能做的事"测完,而且这几步必须在设计阶段就启动,不能留到上线后靠渗透测试兜底。

最后浓缩成三句话:

  1. 每道门都要有锁。 页面、接口、数据三层独立校验,任何一层都不能依赖上一层。
  2. 默认不信任。 没声明权限就拒绝,没校验归属就禁止,宁可多拦不能漏放。
  3. 防线要左移。 设计评审定规矩,编码落地守规矩,测试自动化查规矩。上线后才发现,成本要翻很多倍。
相关推荐
GlobalSign数字证书2 小时前
申请 EV 代码签名证书需要准备哪些材料?
代码规范
深圳老胡2 小时前
STM32F4 OTA 升级双 App 方案设计与实现
笔记·stm32·单片机·代码规范
深圳老胡4 小时前
STM32F4 OTA升级:单App与双App方案优缺点对比
笔记·stm32·单片机·代码规范
这个DBA有点耶20 小时前
Change Buffer深入:二级索引写入的隐形加速器与它的代价
数据库·mysql·代码规范
知守观2 天前
一个半天需求干了三天:代码腐化的五个信号与自查命令
java·后端·代码规范
深圳老胡2 天前
STM32 MCU 国产替代型号简介
笔记·stm32·单片机·代码规范
深圳老胡2 天前
STM32 在 VSCode 下 -O0 与 -Os 编译条件的区别
笔记·stm32·单片机·代码规范
liangsheng_g4 天前
SpringAOP两套代理创建路线设计哲学与开源实践
spring·开源·代码规范
Awei爱emo5 天前
写到一半被叫去修 bug?别再 stash 了,用 Git Worktree 开张"新桌子"
代码规范