背景
在实际业务安全评估里,接口越权是我见得比较多、也比较容易被低估的一类问题。它不像反序列化、RCE 那样“显眼”,但一旦出现在订单、工单、文件、审批、租户管理等模块,影响往往不小。
这篇文章不讨论如何攻击具体系统,而是整理一次典型接口越权排查的思路:如何从现象定位到代码,如何判断是水平越权还是垂直越权,如何把修复建议落到开发可执行的层面。适合用于内部安全测试、代码审计和缺陷复盘。
问题场景
一个常见场景是:系统中存在“查看详情”接口,前端页面通过资源 ID 查询数据。例如订单详情、项目详情、文件详情、客户详情等。
GET /api/order/detail?id=1024正常情况下,用户只能查看自己名下或自己组织范围内的数据。但如果服务端只根据传入的 id 查询记录,没有校验当前登录用户与该资源之间的关系,就可能导致水平越权。
另一个常见场景是后台功能入口被前端隐藏,但接口本身没有做角色校验。例如普通用户无法在菜单里看到“用户管理”,但直接访问接口仍然能查询敏感数据,这类通常属于垂直越权。
技术分析
越权问题的核心不是“接口有没有登录态”,而是“登录态与资源、动作、角色之间是否建立了可信校验”。很多项目只做了认证,没有做好授权。
一般可以从三个维度拆解:
- 认证 Authentication:当前请求是谁发起的,例如 userId、tenantId、session、JWT subject。
- 授权 Authorization:这个用户是否允许执行当前动作,例如 read、update、delete、approve。
- 资源归属 Ownership:目标资源是否属于该用户、部门、租户或授权范围。
很多越权漏洞出在第三点。代码里看起来已经判断了登录态,但实际查询资源时没有带上用户边界。
常见问题代码模式
以下是比较典型的风险写法。为了说明问题,示例做了简化。
// 风险示例:只根据订单 ID 查询,没有校验归属
@GetMapping("/order/detail")
public OrderDTO detail(@RequestParam Long id) {
Order order = orderService.getById(id);
return convert(order);
}这种代码的问题是:只要请求者处于登录状态,并且知道或猜到一个存在的资源 ID,就可能读取不属于自己的数据。
相对安全的做法是,在查询时加入当前用户或租户边界,或者在取出资源后进行明确授权判断。
// 推荐示例:查询条件包含用户边界
@GetMapping("/order/detail")
public OrderDTO detail(@RequestParam Long id) {
Long currentUserId = SecurityContext.getCurrentUserId();
Order order = orderService.getByIdAndUserId(id, currentUserId);
if (order == null) {
throw new BizException("资源不存在或无权限访问");
}
return convert(order);
}如果是多租户系统,不能只校验 userId,还要校验 tenantId。否则可能出现跨租户访问。
// 多租户场景建议同时校验 tenantId
Long currentUserId = SecurityContext.getCurrentUserId();
Long currentTenantId = SecurityContext.getCurrentTenantId();
Order order = orderMapper.selectOne(
Wrappers.<Order>lambdaQuery()
.eq(Order::getId, id)
.eq(Order::getUserId, currentUserId)
.eq(Order::getTenantId, currentTenantId)
);代码审计切入点
做代码审计时,不建议一上来盯着某一个接口反复看。更有效的方法是先找“资源型接口”,再向下追授权逻辑。
我通常会优先关注以下接口特征:
- 参数中出现 id、orderId、userId、fileId、projectId、taskId、tenantId。
- 接口语义包含 detail、info、query、list、download、update、delete、approve。
- Controller 层直接调用 getById、selectById、findById。
- Service 层查询条件只包含主键,没有当前用户、部门、租户条件。
- 导出、下载、预览接口没有单独鉴权。
- 批量操作接口只校验了数组格式,没有逐项校验资源权限。
可以通过静态搜索快速缩小范围。例如在 Java 项目中:
grep -R "selectById\|getById\|findById" src/main/java
grep -R "download\|export\|detail\|delete" src/main/java如果项目使用 MyBatis,也可以关注 XML 或 Mapper 注解中的 SQL 条件,确认是否带有业务边界。
SELECT * FROM orders WHERE id = #{id}上面这种 SQL 在资源详情接口里就需要警惕。更合理的写法通常是:
SELECT * FROM orders
WHERE id = #{id}
AND user_id = #{currentUserId}
AND tenant_id = #{currentTenantId}关键排查步骤
一套比较稳的排查流程如下:
- 确认接口功能:判断该接口操作的资源类型,以及资源是否具备归属关系。
- 确认身份来源:查看当前用户 ID、角色、租户 ID 是从哪里取的,是否来自服务端可信上下文。
- 追踪查询条件:从 Controller 跟到 Service、Mapper,确认 SQL 或 ORM 查询是否包含授权边界。
- 检查角色判断:如果接口是管理功能,确认是否有后端角色校验,而不是只依赖前端菜单隐藏。
- 关注批量操作:批量删除、批量导出、批量审批需要对每一个资源做权限判断。
- 检查异常返回:无权限和不存在建议使用统一响应,避免暴露资源是否存在。
这里有一个经验点:不要只看 Controller 上有没有类似 @PreAuthorize 的注解。注解能解决一部分“能不能进接口”的问题,但未必能解决“能不能操作这条数据”的问题。
水平越权与垂直越权的区别
| 类型 | 典型表现 | 重点检查 |
|---|---|---|
| 水平越权 | 同角色用户访问彼此资源 | 资源归属、用户边界、租户边界 |
| 垂直越权 | 低权限用户访问高权限功能 | 角色校验、权限点、后端访问控制 |
| 跨租户越权 | 一个租户访问另一个租户数据 | tenantId 来源、查询条件、缓存隔离 |
实际项目里这几类可能同时存在。比如一个“导出用户列表”的接口,普通用户能访问属于垂直越权;如果还能通过 tenantId 参数导出其他租户数据,就是跨租户越权。
容易忽略的细节
- 不要信任前端传入的 userId:如果接口参数里允许传 userId,并用它作为查询条件,需要确认是否和当前登录用户匹配,或者该用户是否具备代查权限。
- 下载接口要单独鉴权:文件 URL、预签名地址、附件 ID 都可能成为越权入口。
- 缓存也要带边界:缓存 key 如果只使用资源 ID,在多租户或多用户场景下可能导致数据串读。
- 审批流要校验当前节点:不是登录用户有审批角色就能审批所有单据,还要看是否是当前处理人或候选组。
- 异步任务不要丢失身份上下文:导出、报表、批处理如果在后台执行,需要保存并校验发起人的权限范围。
- 避免过度返回:列表接口即使做了权限过滤,也要控制字段脱敏,减少敏感数据暴露面。
修复建议
修复越权问题时,不建议只在单个接口里临时加 if 判断。更好的方式是结合业务模型做统一控制。
- 资源查询统一封装权限条件,例如自动追加 tenantId、deptId、ownerId。
- 关键操作使用服务端身份上下文,不直接信任客户端传入的用户身份字段。
- 管理类接口使用后端权限注解或拦截器进行角色校验。
- 资源级授权放在 Service 层,避免多个入口绕开 Controller 校验。
- 对批量操作做逐项授权,不能只校验第一个 ID。
- 为敏感接口补充安全回归测试,覆盖普通用户、管理员、跨租户等场景。
一个比较实用的 Service 层封装示例:
public Order getReadableOrder(Long orderId) {
Long userId = SecurityContext.getCurrentUserId();
Long tenantId = SecurityContext.getCurrentTenantId();
Order order = orderMapper.selectOne(
Wrappers.<Order>lambdaQuery()
.eq(Order::getId, orderId)
.eq(Order::getTenantId, tenantId)
);
if (order == null) {
throw new BizException("资源不存在或无权限访问");
}
if (!order.getUserId().equals(userId) && !PermissionUtil.canManageOrder(userId)) {
throw new BizException("资源不存在或无权限访问");
}
return order;
}这样做的好处是,后续详情、更新、取消、导出等多个入口都可以复用同一套资源读取逻辑,减少漏校验。
测试与验证
安全测试阶段可以设计几组低风险验证用例,重点验证授权边界是否生效:
- 用户 A 创建资源,用户 B 尝试读取该资源详情,应返回无权限或不存在。
- 普通用户访问管理接口,应被后端拒绝,而不是只靠前端隐藏菜单。
- 租户 A 的用户传入租户 B 的资源 ID,应无法访问。
- 批量接口中混入无权限资源 ID,应整体拒绝或仅处理有权限部分,并有明确策略。
- 导出任务生成后,下载结果文件时仍应校验发起人身份。
对于自动化回归,可以在集成测试里固定准备两组用户和资源数据,避免每次靠手工验证。
@Test
void userShouldNotReadOthersOrder() {
loginAs(userB);
Result result = orderApi.detail(orderOfUserA.getId());
assertEquals(403, result.getCode());
}注意点
越权修复的难点通常不在代码写法,而在业务边界定义。谁可以看、谁可以改、部门管理员能管理哪些范围、租户管理员是否能跨部门,这些规则需要和业务方确认清楚。
另外,不建议把所有失败都简单返回 403。对外部接口来说,“资源不存在或无权限访问”更稳妥,可以减少资源枚举带来的信息泄露。不过内部管理系统是否需要明确提示,可以根据审计和运维需要权衡。
总结
接口越权是 Web 系统里很常见的问题,表面看是少了一个判断,实际反映的是授权模型没有落到资源层。排查时可以抓住三个关键词:身份、动作、资源。只要能确认“谁在对什么资源做什么动作”,大多数越权问题都能定位清楚。
从长期维护角度看,建议把资源级权限封装到 Service 或统一权限组件里,而不是依赖每个接口开发者手写判断。安全测试也应把详情、下载、导出、批量操作、多租户隔离作为固定检查项,这比事后单点补丁更可靠。
推荐意见