背景
越权问题在 Java Web 项目里很常见,但也很容易被低估。很多系统登录、菜单、角色配置都做得比较完整,接口层却只校验了“是否登录”,没有校验“是否有权访问当前资源”。这类问题在测试环境里不一定显眼,到了真实业务场景,往往会造成订单、工单、用户资料、审批记录等数据被横向访问。
这篇文章整理一次代码审计中比较典型的越权排查思路。重点不在某个漏洞利用技巧,而是如何从代码、接口和数据流三个角度判断权限边界是否可靠,适合做代码审计、自查和安全加固参考。
问题场景
项目是常见的 Spring Boot 后台系统,用户登录后可以查看自己的业务数据。权限模型大致分为三层:
- 登录认证:判断用户是否已登录。
- 菜单权限:控制前端页面和按钮是否显示。
- 数据权限:控制用户只能访问自己有权限的数据。
审计时发现,部分接口依赖前端传入的 userId、deptId 或 recordId 查询数据,后端没有重新确认当前登录用户与目标资源之间的关系。典型代码形态如下:
@GetMapping("/record/detail")
public Result detail(Long recordId) {
BusinessRecord record = recordService.getById(recordId);
return Result.ok(record);
}这段代码本身看起来没有复杂逻辑,但风险点很明确:只要 recordId 可枚举或可获取,接口就会直接返回记录详情。如果记录里包含客户信息、审批意见、附件地址等敏感字段,就会形成水平越权。
技术分析
越权问题通常不是单点缺陷,而是权限校验分散、模型边界不清导致的结果。审计时可以按以下几个维度梳理。
1. 认证不等于授权
很多项目使用拦截器或网关统一判断登录态,例如检查 token 是否有效:
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
String token = request.getHeader("Authorization");
LoginUser loginUser = tokenService.parse(token);
if (loginUser == null) {
response.setStatus(401);
return false;
}
UserContext.set(loginUser);
return true;
}这只能说明请求来自一个已登录用户,不能说明该用户有权限访问目标数据。真正的授权需要结合当前用户、目标资源和操作类型判断。
2. 菜单权限不能替代接口权限
一些后台系统只在前端控制按钮展示,例如没有“详情”按钮就认为用户不能访问详情接口。这种设计不可靠,因为接口本身仍然可被直接请求。审计时需要重点检查:
- Controller 方法是否有权限注解或显式校验。
- Service 层是否根据当前用户追加数据范围条件。
- 仅前端隐藏按钮但后端无校验的接口。
- 导出、下载、详情、删除、审批等高风险操作。
3. ID 参数是越权高发点
常见高风险参数包括:
- userId、accountId、memberId
- orderId、recordId、caseId、ticketId
- deptId、tenantId、orgId
- fileId、attachmentId
如果接口直接信任这些参数,就需要进一步确认是否存在资源归属校验。例如下面这种写法就不够安全:
@PostMapping("/order/cancel")
public Result cancel(Long orderId) {
orderService.cancel(orderId);
return Result.ok();
}更稳妥的方式是基于当前登录用户做条件更新,避免只按主键操作:
@PostMapping("/order/cancel")
public Result cancel(Long orderId) {
Long currentUserId = UserContext.getUserId();
boolean success = orderService.cancelOwnOrder(orderId, currentUserId);
if (!success) {
return Result.fail("无权操作或订单不存在");
}
return Result.ok();
}public boolean cancelOwnOrder(Long orderId, Long userId) {
int rows = orderMapper.cancelByIdAndUserId(orderId, userId);
return rows == 1;
}对应 SQL 可以写成:
UPDATE orders
SET status = 'CANCELED'
WHERE id = #{orderId}
AND user_id = #{userId}
AND status = 'WAIT_PAY'这样即使传入了别人的 orderId,也不会影响不属于当前用户的数据。
关键排查步骤
步骤一:从路由和接口清单入手
优先整理 Controller 层接口,标记以下类型:
- 详情查询:/detail、/info、/view
- 列表查询:/list、/page、/search
- 状态变更:/update、/delete、/cancel、/approve
- 文件相关:/download、/preview、/export
- 管理类接口:/admin、/manage、/config
可以用简单命令先定位接口入口:
grep -R "@GetMapping\|@PostMapping\|@RequestMapping\|@DeleteMapping\|@PutMapping" src/main/java如果项目使用权限注解,也可以反向查找未加注解的接口:
grep -R "@PreAuthorize\|@RequiresPermissions" src/main/java这一步不是为了机械判断“没注解就是漏洞”,而是先找出需要人工确认的数据访问入口。
步骤二:追踪当前用户是否参与查询条件
对于涉及业务数据的接口,重点看当前用户信息是否参与了查询或更新条件。安全性较差的常见写法:
public BusinessRecord getDetail(Long id) {
return recordMapper.selectById(id);
}更推荐的方式是把数据范围放进查询条件:
public BusinessRecord getDetail(Long id) {
LoginUser user = UserContext.get();
return recordMapper.selectDetailWithScope(id, user.getUserId(), user.getDeptIds());
}SELECT *
FROM business_record
WHERE id = #{id}
AND (
owner_id = #{userId}
OR dept_id IN
#{deptId}
)如果系统有租户隔离,还应当检查 tenant_id 是否始终参与条件。多租户系统里漏掉 tenant_id,影响通常比普通水平越权更大。
步骤三:确认角色权限和数据权限是否分离
角色权限解决“能不能访问某个功能”,数据权限解决“能访问哪些数据”。两者不能混在一起。比如财务角色可以访问订单页面,但不代表可以访问所有部门订单。
比较清晰的授权判断可以拆成两层:
public OrderDetail getOrderDetail(Long orderId) {
LoginUser user = UserContext.get();
permissionService.checkFunctionPermission(user, "order:detail");
OrderDetail detail = orderMapper.selectDetailByScope(
orderId,
user.getTenantId(),
user.getUserId(),
user.getDeptIds()
);
if (detail == null) {
throw new AccessDeniedException("无权访问该订单");
}
return detail;
}这样排查问题时也更清楚:是功能权限缺失,还是数据范围缺失。
步骤四:关注导出和下载接口
导出接口经常复用列表查询条件,但有些项目为了性能会重新写一套 SQL,导致列表有数据权限,导出没有数据权限。文件下载也是越权高发点,常见问题是只校验 fileId 是否存在,不校验文件所属业务对象是否属于当前用户。
不建议直接这样下载:
@GetMapping("/file/download")
public void download(Long fileId, HttpServletResponse response) {
FileInfo file = fileService.getById(fileId);
fileService.writeToResponse(file, response);
}建议把文件和业务资源的权限关系一起校验:
@GetMapping("/file/download")
public void download(Long fileId, HttpServletResponse response) {
LoginUser user = UserContext.get();
FileInfo file = fileService.getAccessibleFile(fileId, user);
if (file == null) {
throw new AccessDeniedException("无权下载该文件");
}
fileService.writeToResponse(file, response);
}步骤五:用最小化测试验证风险
在授权测试环境中,可以准备两个普通账号 A 和 B,各自创建一条业务数据。验证时不需要做破坏性操作,重点确认读取和状态变更是否被限制:
- A 登录后访问 A 自己的数据,应正常返回。
- A 登录后请求 B 的 recordId,应返回 403、404 或空结果。
- A 尝试对 B 的数据执行更新或删除,应失败且数据不变。
- 导出、下载、预览接口也要重复同样验证。
对于状态变更类接口,建议只在测试数据上操作,或者使用只读环境复现,避免影响真实业务。
常见修复建议
1. 不信任前端传入的身份字段
userId、tenantId、deptId 这类字段尽量从服务端登录上下文获取,不应由普通用户请求参数决定。前端传入的字段最多作为筛选条件,还需要和服务端数据范围求交集。
Long tenantId = UserContext.getTenantId();
Long userId = UserContext.getUserId();2. 在 Service 层统一承载授权逻辑
Controller 层适合做参数接收和响应封装,不建议把复杂权限判断散落在各个 Controller 方法里。Service 层更接近业务语义,适合判断资源归属和操作权限。
3. 使用“按范围查询”替代“先查再判断”
先 selectById 再判断 ownerId,容易在后续改动中漏掉判断,也可能产生额外的数据暴露风险。更稳妥的做法是在 SQL 条件中直接加入 user_id、tenant_id、dept_id 等范围限制。
4. 对失败结果保持克制
对于无权访问的数据,可以返回 403,也可以返回 404,取决于业务设计。关键是不要在错误信息里暴露资源是否存在、归属人、内部状态等额外信息。
5. 补充审计日志
对于敏感数据访问和状态变更,建议记录必要日志,便于后续排查:
- 当前用户 ID、租户 ID、部门 ID
- 访问资源 ID 和资源类型
- 操作类型
- 授权结果
- 请求来源 IP、User-Agent
日志不要记录完整身份证号、银行卡号、token 等敏感字段,必要时做脱敏。
代码审计检查清单
| 检查项 | 关注点 | 风险表现 |
|---|---|---|
| 详情接口 | 是否只按 id 查询 | 可查看他人数据 |
| 列表接口 | 是否追加数据范围 | 跨部门、跨租户查询 |
| 更新接口 | WHERE 条件是否包含归属限制 | 修改他人数据 |
| 删除接口 | 是否校验资源归属 | 删除非本人资源 |
| 导出接口 | 是否复用安全查询条件 | 批量导出越权数据 |
| 文件接口 | fileId 是否绑定业务权限 | 下载他人附件 |
| 多租户接口 | tenant_id 是否强制参与条件 | 租户间数据泄露 |
注意点
- 不要只依赖前端路由、按钮隐藏或菜单权限。
- 不要把管理员逻辑和普通用户逻辑混在同一个接口里,除非权限判断非常清楚。
- 批量接口要逐条校验权限,不能只校验列表中的第一个 id。
- 缓存数据也要带上租户和用户维度,避免缓存键设计不当导致串数据。
- 异步任务、消息消费、定时导出同样需要考虑数据范围,不只是 HTTP 接口。
总结
越权问题的核心不是某个注解有没有加,而是后端是否始终基于当前登录主体判断目标资源是否可访问。比较可靠的做法是:认证统一做,功能权限明确做,数据权限靠近业务查询做,敏感操作按资源归属做。
实际审计时,我通常先从 Controller 接口清单出发,筛出带 id、userId、tenantId、fileId 的接口,再追踪 Service 和 SQL 是否包含当前用户的数据范围。这个方法不复杂,但覆盖面比较稳定,尤其适合后台管理系统、多租户系统和工单审批类系统的自查。
安全加固的目标不是让代码看起来“有权限控制”,而是让每一次数据读取和状态变更都能回答一个问题:当前用户为什么有权访问这条资源?
推荐意见