跳转到帖子

背景

接口越权是 Web 系统里最常见、也最容易被低估的问题之一。它不像 SQL 注入、RCE 那样有明显的危险函数或特征 payload,很多时候只是业务代码里少了一次用户身份与资源归属关系的校验。

最近整理一个 Spring Boot 项目的代码审计记录,发现其中几类越权问题具有代表性:查询接口只校验登录态,不校验数据归属;更新接口信任前端传入的 userId;管理接口只做菜单隐藏,没有后端权限判断。这里把审计思路、定位方式和修复建议整理出来,适合做长期自查清单。

本文只讨论授权逻辑缺陷的识别与防护,不涉及未授权攻击、撞库、批量探测等操作。

典型场景

项目采用常见的 Spring Boot + MyBatis 架构,认证通过 JWT 完成。用户登录后,后端从 token 中解析 userId,并放入上下文。理论上,业务接口应该基于当前登录用户判断其是否有权访问目标资源。

但在实际代码里,经常会看到下面几类写法:

  • 接口只判断用户是否登录,未判断资源是否属于当前用户。
  • 查询、修改、删除接口直接使用前端传入的 id。
  • DTO 中包含 userId、tenantId、roleId 等敏感字段,并被直接用于数据库操作。
  • 后台管理接口依赖前端路由控制,后端缺少角色或权限点校验。
  • 多租户系统只在列表查询中带 tenant_id,详情或导出接口漏掉租户条件。

问题代码示例

下面是一个简化后的订单详情接口。表面上看已经有登录校验,但授权逻辑是不完整的。

@GetMapping("/order/{id}")
public OrderDetailVO detail(@PathVariable Long id) {
    Long currentUserId = UserContext.getUserId();
    if (currentUserId == null) {
        throw new BizException("not login");
    }
    return orderService.getDetail(id);
}

service 层继续向下看:

public OrderDetailVO getDetail(Long id) {
    Order order = orderMapper.selectById(id);
    if (order == null) {
        throw new BizException("order not found");
    }
    return convert(order);
}

问题点在于:当前用户是否登录,与其是否能访问这个订单,是两个不同的问题。这里缺少订单归属校验。如果订单 id 是连续数字,风险会进一步放大。

审计时如何快速定位

我通常会从接口入参、权限注解、数据库条件三个方向交叉检查,而不是单纯搜索某个关键词。

1. 关注高风险入参

以下字段一旦出现在 Controller 入参或 DTO 中,就需要重点看后端是否重新校验:

  • id、orderId、fileId、projectId、deptId
  • userId、memberId、ownerId
  • tenantId、orgId、companyId
  • roleId、permissionId
  • status、amount、price、level 等可影响业务状态的字段

可以用简单命令先扫一遍接口层代码:

grep -R "@RequestMapping\|@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping" src/main/java

grep -R "userId\|tenantId\|roleId\|ownerId\|deptId" src/main/java

这类搜索不能直接证明漏洞存在,但能快速收敛审计范围。

2. 看权限注解是否只是认证

很多项目会自定义注解,例如:

@LoginRequired
@GetMapping("/file/download")
public void download(Long fileId, HttpServletResponse response) {
    fileService.download(fileId, response);
}

这里的 @LoginRequired 只能说明用户已登录,不能说明用户对 fileId 有访问权。审计时需要进一步追踪 fileService.download 是否校验:

  • 文件记录是否存在。
  • 文件 owner_id 是否等于当前用户。
  • 如果是团队资源,当前用户是否属于对应项目或组织。
  • 如果是多租户系统,tenant_id 是否匹配。

3. 检查 SQL 条件是否缺失归属字段

越权问题最终经常落在 SQL 条件上。例如下面的查询只按 id 查:

SELECT id, user_id, title, amount
FROM orders
WHERE id = #{id}

更稳妥的做法是把归属关系放入查询条件:

SELECT id, user_id, title, amount
FROM orders
WHERE id = #{id}
  AND user_id = #{currentUserId}

多租户场景下也类似:

SELECT id, tenant_id, name
FROM project
WHERE id = #{id}
  AND tenant_id = #{currentTenantId}

如果系统支持管理员、普通用户、项目成员等多种角色,不能简单靠一个 user_id 条件解决,需要把权限模型设计清楚。

横向越权与纵向越权

审计报告里建议把越权问题分成两类描述,方便研发理解和修复。

类型表现常见原因修复重点
横向越权普通用户访问同级用户资源只校验登录态,不校验资源归属增加 owner、tenant、project 成员关系校验
纵向越权低权限用户访问高权限功能前端隐藏菜单,后端无权限点判断后端统一鉴权,按角色或权限点限制

横向越权更多出现在详情、下载、导出、修改、删除接口;纵向越权更多出现在后台管理、审批、配置、用户管理等模块。

修复思路

1. 不信任前端传入的身份字段

比较危险的写法是允许前端传 userId,然后直接更新:

@PostMapping("/profile/update")
public void update(@RequestBody UserProfileDTO dto) {
    userService.updateProfile(dto);
}
public void updateProfile(UserProfileDTO dto) {
    userMapper.updateByUserId(dto.getUserId(), dto);
}

应该以后端上下文中的当前用户为准:

@PostMapping("/profile/update")
public void update(@RequestBody UserProfileDTO dto) {
    Long currentUserId = UserContext.getUserId();
    userService.updateProfile(currentUserId, dto);
}
public void updateProfile(Long currentUserId, UserProfileDTO dto) {
    userMapper.updateByUserId(currentUserId, dto);
}

DTO 中如果没有必要,不要暴露 userId、tenantId、roleId 等字段。即使前端传了,也不能直接作为权限依据。

2. 在 service 层做业务授权

只在 Controller 层写校验容易遗漏,尤其同一个 service 方法可能被多个入口调用。建议把核心授权放在 service 层,Controller 只负责参数接收和基础校验。

public OrderDetailVO getDetail(Long currentUserId, Long orderId) {
    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        throw new BizException("order not found");
    }
    if (!order.getUserId().equals(currentUserId)) {
        throw new BizException("access denied");
    }
    return convert(order);
}

更推荐直接在查询阶段带上归属条件,避免先查出不属于当前用户的数据:

public OrderDetailVO getDetail(Long currentUserId, Long orderId) {
    Order order = orderMapper.selectByIdAndUserId(orderId, currentUserId);
    if (order == null) {
        throw new BizException("order not found");
    }
    return convert(order);
}

这类写法还有一个好处:对外返回统一的 not found,减少资源存在性暴露。

3. 后台功能使用权限点控制

对于后台接口,不建议只依赖角色名称硬编码,例如判断 role == admin。更可维护的方式是权限点控制:

@RequiresPermission("user:delete")
@DeleteMapping("/admin/user/{id}")
public void deleteUser(@PathVariable Long id) {
    adminUserService.deleteUser(id);
}

权限注解背后至少要完成几件事:

  • 确认用户已登录。
  • 加载用户角色与权限点。
  • 判断当前接口需要的权限点是否存在。
  • 必要时判断数据范围,例如只能管理本部门用户。

如果只做功能级权限,不做数据范围控制,也会出现“部门管理员管理其他部门用户”的问题。

4. 多租户系统要统一注入 tenant 条件

多租户项目里最容易漏的是详情、统计、导出、异步任务。列表页一般会加 tenant_id,但其他入口常被忽略。

可以考虑在以下层面做统一约束:

  • 登录后从 token 或 session 中提取 currentTenantId。
  • 所有租户隔离表必须包含 tenant_id。
  • Mapper 查询统一带 tenant_id 条件。
  • 对导出、下载、详情接口做专项用例覆盖。
  • 定时任务、消息消费场景明确租户上下文来源。

不要让前端传 tenantId 决定当前租户。如果确实支持用户切换租户,也应在后端校验用户是否属于目标租户。

测试与验证建议

安全测试不需要做破坏性操作,重点是构造最小化验证用例,确认授权逻辑是否生效。

  • 准备两个普通测试账号 A、B,各自创建一条资源。
  • A 登录后访问自己的资源,确认正常。
  • A 请求 B 的资源 id,预期返回 404 或 access denied。
  • 低权限账号访问管理接口,预期被拒绝。
  • 部门管理员访问其他部门数据,预期被拒绝。
  • 导出、下载、批量查询接口重复上述验证。

如果项目有自动化测试,可以补充类似用例:

@Test
void userShouldNotReadOthersOrder() {
    Long userA = createUser();
    Long userB = createUser();
    Long orderB = createOrder(userB);

    mockLogin(userA);

    mockMvc.perform(get("/order/" + orderB))
            .andExpect(status().isForbidden());
}

注意这里的状态码需要结合项目规范,不一定必须是 403。有些系统为了避免资源枚举,会统一返回 404。

审计清单

实际审计时,可以按下面清单逐项过一遍:

  • 详情接口是否带 owner_id、tenant_id 或成员关系校验。
  • 修改、删除接口是否校验当前用户对目标资源的权限。
  • 下载、预览、导出接口是否存在授权判断。
  • 批量接口是否逐条校验权限,而不是只校验第一个 id。
  • 后台接口是否有后端权限点控制。
  • 数据范围是否生效,例如部门、项目、租户隔离。
  • DTO 中是否暴露可被滥用的 userId、roleId、tenantId。
  • 异步任务、消息消费是否丢失用户或租户上下文。
  • 缓存 key 是否包含用户或租户维度,避免串数据。

容易忽略的几个细节

批量操作:批量删除、批量导出时,不能只判断 ids 非空或数量限制,需要对每个 id 做归属校验。更推荐用带权限条件的 SQL 一次性限制范围。

DELETE FROM file
WHERE id IN
  <foreach collection="ids" item="id" open="(" separator="," close=")">
    #{id}
  </foreach>
AND owner_id = #{currentUserId}

缓存:如果缓存 key 只使用资源 id,而资源内容与用户权限有关,可能出现串读。缓存 key 应包含 userId、tenantId 或权限维度。

String key = "order:detail:" + currentUserId + ":" + orderId;

导出接口:导出经常绕过分页查询逻辑,重新写一套 SQL,很容易漏权限条件。建议复用同一个查询条件构造器。

前端路由:菜单隐藏只能改善体验,不能作为安全边界。所有关键接口必须由后端判断权限。

总结

接口越权的核心不是“有没有登录”,而是“当前主体是否有权访问当前资源或执行当前动作”。审计时不要只看 Controller 上有没有认证注解,还要继续追到 service、mapper 和数据范围控制。

比较稳妥的修复原则是:身份从后端上下文取,资源访问带归属条件,后台功能走权限点,数据范围在服务端统一收敛。对于详情、下载、导出、批量操作、多租户接口,建议作为固定审计项长期保留。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

游客
抱歉,你的帖子内容包括我们不允许的字词。请编辑你的帖子,删除下面高亮的屏蔽字。
添加意见…