跳转到帖子

背景

越权问题在业务系统里很常见,尤其是后台管理、工单、订单、租户数据这类接口。它不像 SQL 注入或命令执行那样容易通过特征命中,更多依赖对业务对象、权限模型和代码路径的理解。本文整理一次偏通用的 Java Web 越权审计思路,重点放在如何定位问题、如何验证影响、以及如何把修复做扎实。

为了避免涉及具体目标,下面使用一个简化的业务场景:系统中存在“项目 Project”,不同用户只能访问自己所属租户或被授权的项目。后端使用 Spring Boot + MyBatis,登录态通过 JWT 或 Session 识别用户身份。

问题场景

典型风险接口如下:

GET /api/project/detail?id=1001
GET /api/project/task/list?projectId=1001
POST /api/project/update
{
  "id": 1001,
  "name": "new-name",
  "description": "xxx"
}

从接口形态看,资源 ID 直接由前端传入。如果后端只根据 id 查询或更新,而没有校验当前用户是否有该资源权限,就可能出现水平越权。

常见代码形态如下:

@GetMapping("/detail")
public ProjectDTO detail(@RequestParam Long id) {
    Project project = projectService.getById(id);
    return ProjectConverter.toDTO(project);
}

这类代码的问题不在于 getById 本身,而在于缺少“当前用户是否可以访问该 id 对应资源”的约束。越权审计时要关注的不是接口有没有登录,而是登录之后是否做了对象级授权。

技术分析

1. 权限校验应分层看

实际代码里,权限通常分为几类:

  • 登录认证:确认用户是谁,例如 JWT、Session、SSO。
  • 功能权限:确认用户能不能访问某个菜单或接口,例如 RBAC。
  • 数据权限:确认用户能不能访问某条记录,例如租户、部门、项目成员关系。
  • 字段权限:确认用户能不能查看或修改某些敏感字段。

很多系统只做到了前两层。比如使用注解限制接口需要某个角色:

@PreAuthorize("hasAuthority('project:view')")
@GetMapping("/detail")
public ProjectDTO detail(@RequestParam Long id) {
    return projectService.detail(id);
}

这只能说明用户具有“查看项目”的功能权限,但不能说明他可以查看 id=1001 的项目。水平越权往往就发生在这里。

2. 从 Controller 到 Mapper 追踪资源 ID

审计时我习惯先梳理所有从请求参数进入的资源标识:

  • id、userId、projectId、orderId、tenantId、deptId、orgId
  • 批量参数 ids、idList
  • 路径参数 /project/{id}
  • 请求体中的嵌套对象,例如 task.projectId

然后追踪它们最终进入了哪些查询或更新语句。典型危险模式如下:

select * from project where id = #{id}
update project set name = #{name} where id = #{id}

如果 SQL 只按主键处理,没有附加租户、成员、部门等约束,就需要继续看 Service 层有没有提前校验。如果两边都没有,那基本就是一个越权点。

3. 批量接口更容易漏校验

单条详情接口有时会被开发注意到,但批量删除、批量导出、批量修改经常遗漏。

@PostMapping("/delete")
public Boolean delete(@RequestBody List<Long> ids) {
    return projectService.removeByIds(ids);
}

这种写法看上去很简洁,但风险很明显:只要 ids 中混入其他用户可猜测或可枚举的资源 ID,就可能删除不属于自己的数据。修复时不应只在前端限制勾选范围,后端必须基于当前用户过滤可操作集合。

关键步骤

1. 接口资产梳理

如果项目有 OpenAPI、Swagger、YApi 或网关日志,可以先导出接口清单。没有文档时,可以从 Controller 注解入手:

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

重点筛选包含资源 ID 的接口。经验上,下面这些接口优先级较高:

  • 详情、编辑、删除、导出、下载
  • 审批、转派、归档、关闭
  • 用户资料、地址、发票、合同
  • 文件预览、附件下载
  • 后台管理中的租户、部门、角色、成员关系

2. 建立权限模型基线

不要一上来就改代码,先确认业务规则。比如项目访问权限可能来自:

  • 项目 owner
  • 项目成员表 project_member
  • 同租户 tenant_id
  • 同部门或下级部门
  • 管理员角色,但也可能只限本租户管理员

不同系统规则不同,修复前要把“谁能访问什么”定义清楚,否则很容易修出新的业务问题。

3. 复现验证方式

安全测试时建议准备两个普通账号 A 和 B,分别创建或持有不同资源。验证时只在授权测试环境进行,不对真实生产数据做破坏性操作。

验证逻辑可以简单表示为:

账号A登录后访问:/api/project/detail?id=A_project_id  应允许
账号A登录后访问:/api/project/detail?id=B_project_id  应拒绝
账号B登录后访问:/api/project/detail?id=A_project_id  应拒绝

对于更新类接口,建议使用无害字段或测试资源验证,并记录修改前后的数据状态,避免误判。

4. 代码级修复示例

一种直接修复方式是在 Service 层统一校验资源归属:

public ProjectDTO detail(Long projectId) {
    Long currentUserId = SecurityContext.getCurrentUserId();

    boolean allowed = projectMemberMapper.exists(projectId, currentUserId);
    if (!allowed) {
        throw new BizException("无权访问该项目");
    }

    Project project = projectMapper.selectById(projectId);
    return ProjectConverter.toDTO(project);
}

如果系统是强租户模型,查询条件应带上 tenant_id,避免单纯按 id 查询:

select *
from project
where id = #{id}
  and tenant_id = #{currentTenantId}

更新和删除同理,不建议先 select 再 update 时只校验 select,实际 update 语句也最好带数据权限条件,降低竞态和遗漏风险:

update project
set name = #{name}, description = #{description}
where id = #{id}
  and tenant_id = #{currentTenantId}

对于成员关系授权:

update project p
set p.name = #{name}
where p.id = #{id}
  and exists (
      select 1
      from project_member pm
      where pm.project_id = p.id
        and pm.user_id = #{currentUserId}
  )

5. 批量接口修复

批量接口不能只检查 ids 非空。比较稳妥的方式是先计算当前用户可操作的资源集合,再进行差集判断。

public void deleteProjects(List<Long> ids) {
    Long currentUserId = SecurityContext.getCurrentUserId();

    List<Long> allowedIds = projectMapper.selectAllowedIds(ids, currentUserId);
    if (allowedIds.size() != ids.size()) {
        throw new BizException("包含无权操作的项目");
    }

    projectMapper.deleteByIds(allowedIds);
}

对应 SQL 示例:

select p.id
from project p
join project_member pm on pm.project_id = p.id
where p.id in
<foreach collection="ids" item="id" open="(" separator="," close=")">
  #{id}
</foreach>
and pm.user_id = #{currentUserId}

注意,这里选择“只要包含无权资源就整体拒绝”,是为了避免调用方通过返回差异推断哪些资源存在。具体策略也可以根据业务改成“只处理有权部分”,但要明确返回语义。

6. 文件下载接口要单独看

附件下载很容易绕过业务详情页的权限控制。常见写法是:

GET /api/file/download?fileId=8899

如果 fileId 只关联文件表,而文件表没有业务对象归属校验,就可能直接下载其他人的附件。建议文件表至少保留业务类型、业务 ID、租户 ID、创建人等字段,并在下载时回查业务对象权限。

public FileStream download(Long fileId) {
    FileInfo file = fileMapper.selectById(fileId);
    if (file == null) {
        throw new BizException("文件不存在");
    }

    permissionService.checkBusinessAccess(
        file.getBizType(),
        file.getBizId(),
        SecurityContext.getCurrentUser()
    );

    return storageService.open(file.getStorageKey());
}

注意点

1. 不要信任前端传入的 tenantId、userId

一些接口会从请求参数里接收 tenantId 或 userId,然后用它做数据过滤:

GET /api/project/list?tenantId=2

如果后端直接使用该 tenantId,本质上还是把权限交给了客户端。正确做法是从登录态或服务端上下文获取当前租户、当前用户,再决定查询范围。

2. 管理员也要区分范围

“管理员”不是一个绝对概念。很多 SaaS 系统中,租户管理员只能管理本租户;部门管理员只能管理本部门及下级部门。如果代码里只判断 role=admin,很容易导致跨租户越权。

3. 统一拦截不等于解决所有数据权限

网关、Filter、Interceptor 可以处理登录认证和部分接口权限,但对象级授权通常需要业务上下文。比如同一个 fileId 对应哪个项目、当前用户是不是项目成员,这些信息往往只有 Service 层或数据库能判断。

4. 返回信息要克制

无权访问时,返回“资源不存在”还是“无权访问”要结合业务。对外部接口,过于明确的错误信息可能帮助枚举资源。常见做法是对敏感资源统一返回 404 或通用错误,对内部后台则保留必要审计日志。

5. 审计日志要记录关键字段

越权修复后,建议记录异常访问行为,至少包括:

  • 当前用户 ID、租户 ID
  • 访问接口和资源 ID
  • 请求来源 IP、User-Agent
  • 拒绝原因
  • 请求时间和 traceId

日志不应记录敏感明文,如密码、完整 Token、身份证号等。

修复验证清单

检查项验证方式预期结果
详情接口A 账号访问 B 账号资源 ID拒绝访问或返回不存在
更新接口A 账号提交 B 账号资源 ID不产生数据变更
删除接口批量 ids 混入无权资源整体拒绝或仅处理授权资源,语义明确
导出接口修改查询条件扩大范围导出数据仍受服务端权限约束
附件下载直接请求无权 fileId无法下载文件内容
管理员接口租户管理员访问其他租户资源拒绝访问

总结

越权问题的核心不是“接口有没有登录”,而是“用户是否被授权访问这个具体对象”。审计时可以围绕资源 ID 追踪数据流,从 Controller、Service、Mapper 到 SQL 条件逐层确认。修复时不要依赖前端限制,也不要只靠菜单权限,关键操作必须在服务端结合当前用户、租户、成员关系等信息做对象级授权。

长期来看,建议把数据权限能力沉淀为统一组件或基础服务,但不要为了统一而忽略业务差异。越权漏洞最容易出现在“看起来通用、实际上缺少上下文”的地方。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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