背景
这篇整理来自最近一次内部系统代码审计复盘。系统是比较典型的 Spring Boot 后端,前端通过 JWT 登录后调用 REST API,权限模型包含用户、角色、部门、项目四层关系。问题本身不算新,但在真实项目里很常见:接口做了登录校验,也做了菜单权限控制,但业务数据权限没有落到具体资源上,最终形成了水平越权。
这里不讨论攻击利用,也不提供针对真实站点的操作指引。重点放在代码审计时如何定位这类问题、如何构造本地复现、以及修复时需要注意哪些边界。
问题场景
审计中发现一个项目详情接口,大致形式如下:
GET /api/project/detail?id=1024后端控制器只校验了用户是否登录,以及是否具备“项目查看”菜单权限:
@GetMapping("/detail")
@PreAuthorize("hasAuthority('project:view')")
public ProjectDetailVO detail(@RequestParam Long id) {
return projectService.getDetail(id);
}这个写法的问题在于,project:view 只是功能权限,表示用户能不能访问“查看项目”这个功能,但并不表示用户可以查看任意项目。若服务层没有进一步确认当前用户与项目之间的关系,就容易出现水平越权。
技术分析
越权问题通常分两类:
垂直越权:普通用户访问管理员功能,例如没有管理权限却能调用管理接口。
水平越权:同权限级别用户访问彼此的数据,例如 A 用户查看 B 用户所属项目。
在很多后台系统中,垂直越权相对容易通过菜单权限、注解、网关策略发现;水平越权更依赖业务上下文,自动化工具不一定能准确判断。
本次问题的核心在服务层:
public ProjectDetailVO getDetail(Long id) {
Project project = projectMapper.selectById(id);
if (project == null) {
throw new BizException("项目不存在");
}
return convert(project);
}这里直接按主键查询项目,没有关联当前登录用户。只要调用者具备接口访问权限,传入不同的项目 ID 就可能拿到不属于自己的数据。
进一步查看项目表结构,可以看到存在组织归属字段:
CREATE TABLE project (
id BIGINT PRIMARY KEY,
name VARCHAR(128),
owner_user_id BIGINT,
dept_id BIGINT,
status TINYINT,
deleted TINYINT DEFAULT 0
);系统还有项目成员表:
CREATE TABLE project_member (
id BIGINT PRIMARY KEY,
project_id BIGINT,
user_id BIGINT,
role_code VARCHAR(64)
);从业务含义看,用户能否查看项目,至少要满足以下条件之一:
用户是项目负责人;
用户是项目成员;
用户属于项目所在部门,且具备部门数据权限;
用户具备明确的全局管理权限。
也就是说,接口权限只是第一层,资源权限必须在查询条件或服务层逻辑里体现。
本地复现思路
建议在测试环境或本地环境复现,不要对未授权系统做验证。可以准备两个普通用户和两个项目:
| 用户 | 项目 | 关系 |
| userA | projectA | 负责人 |
| userB | projectB | 负责人 |
然后用 userA 的身份访问 projectA,确认正常;再访问 projectB,如果仍然返回详情,就说明存在水平越权。
本地调试时可以重点观察三处:
Controller 是否只做了功能权限判断;
Service 是否获取了当前登录用户信息;
Mapper SQL 是否带上了用户、部门、成员关系等限制条件。
例如在服务层打断点,关注当前用户上下文:
LoginUser loginUser = SecurityContextHolderUtil.getLoginUser();
Long currentUserId = loginUser.getUserId();
Set<String> permissions = loginUser.getPermissions();如果后续查询完全没有使用 currentUserId 或数据权限范围,就需要重点检查。
修复方案
修复有两种常见方式:一种是在服务层先判断权限再查询详情;另一种是把权限条件下推到 SQL 查询中。实际项目里更推荐将查询和权限约束合并,避免先查出敏感对象再判断失败。
方式一:服务层显式校验
public ProjectDetailVO getDetail(Long id) {
LoginUser loginUser = SecurityContextHolderUtil.getLoginUser();
Long userId = loginUser.getUserId();
Project project = projectMapper.selectById(id);
if (project == null || project.getDeleted() == 1) {
throw new BizException("项目不存在");
}
if (!canViewProject(userId, project)) {
throw new AccessDeniedException("无权访问该项目");
}
return convert(project);
}private boolean canViewProject(Long userId, Project project) {
if (Objects.equals(project.getOwnerUserId(), userId)) {
return true;
}
if (projectMemberMapper.exists(project.getId(), userId)) {
return true;
}
return dataScopeService.hasDeptScope(userId, project.getDeptId());
}这种方式逻辑清晰,适合权限规则复杂、需要复用判断逻辑的场景。但要注意,不要在返回错误信息时暴露资源存在性。比如“项目存在但无权限”和“项目不存在”是否区分,需要根据业务安全要求决定。
方式二:SQL 层加数据范围
将当前用户 ID 作为查询条件传入 Mapper:
public ProjectDetailVO getDetail(Long id) {
LoginUser loginUser = SecurityContextHolderUtil.getLoginUser();
Project project = projectMapper.selectVisibleById(id, loginUser.getUserId());
if (project == null) {
throw new BizException("项目不存在或无权访问");
}
return convert(project);
}对应 SQL 可以写成:
SELECT p.*
FROM project p
LEFT JOIN project_member pm
ON pm.project_id = p.id
AND pm.user_id = #{userId}
WHERE p.id = #{projectId}
AND p.deleted = 0
AND (
p.owner_user_id = #{userId}
OR pm.id IS NOT NULL
)如果还涉及部门数据权限,可以将用户可见部门集合加入条件:
SELECT p.*
FROM project p
LEFT JOIN project_member pm
ON pm.project_id = p.id
AND pm.user_id = #{userId}
WHERE p.id = #{projectId}
AND p.deleted = 0
AND (
p.owner_user_id = #{userId}
OR pm.id IS NOT NULL
OR p.dept_id IN
<foreach collection="deptIds" item="deptId" open="(" separator="," close=")">
#{deptId}
</foreach>
)需要注意,当 deptIds 为空时不能直接拼出非法 SQL。可以在业务层提前处理,或者在动态 SQL 中增加分支。
审计时的关键检查点
这类问题可以通过固定清单提高发现率。
按 ID 查询详情:如
detail?id=、get/{id}、info/{id},重点看是否限制当前用户可见范围。列表和详情权限不一致:列表接口做了数据过滤,但详情接口直接按 ID 查询,这是非常常见的问题。
导出接口:导出往往复用了旧 SQL,容易缺少数据权限。
批量操作接口:如批量删除、批量修改状态,需要逐项校验资源归属。
附件下载接口:文件 ID、业务 ID、路径映射都要校验业务归属,不应只校验登录态。
前端隐藏按钮不算权限:前端控制只能改善体验,后端必须独立判断。
批量接口的特殊处理
批量接口不要只校验其中一个 ID,也不要简单相信前端传入的数据范围。比如:
@PostMapping("/delete")
public void delete(@RequestBody List<Long> ids) {
projectService.deleteBatch(ids);
}更稳妥的做法是先查询当前用户可操作的资源集合,再和传入集合做比对:
public void deleteBatch(List<Long> ids) {
LoginUser loginUser = SecurityContextHolderUtil.getLoginUser();
List<Long> visibleIds = projectMapper.selectDeletableIds(ids, loginUser.getUserId());
if (visibleIds.size() != ids.size()) {
throw new AccessDeniedException("存在无权操作的数据");
}
projectMapper.logicDeleteBatch(ids);
}如果系统对并发一致性要求较高,还要考虑校验和修改之间的时间窗口。可以在更新语句中也带上权限条件:
UPDATE project p
SET p.deleted = 1
WHERE p.id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
AND p.owner_user_id = #{userId}这样即使校验后资源状态发生变化,也不会误操作不该处理的数据。
日志与告警
越权修复不只是加判断,也建议补充审计日志。日志里不需要记录敏感业务内容,但应记录必要定位信息:
用户 ID;
接口路径;
资源类型和资源 ID;
拒绝原因;
请求时间和来源 IP。
示例:
log.warn("access denied: userId={}, resourceType=project, resourceId={}, path={}",
loginUser.getUserId(), projectId, request.getRequestURI());如果短时间内同一用户连续访问大量无权限资源,可以触发风控或安全告警。但这里要控制误报,避免把正常使用中的权限变更误判成异常。
测试用例建议
修复后建议补充单元测试或集成测试,至少覆盖以下场景:
项目负责人可以查看项目;
项目成员可以查看项目;
无关系用户不能查看项目;
部门权限用户只能查看授权部门项目;
已删除项目不可见;
批量操作中混入无权 ID 时整体失败;
管理员或全局权限账号按预期放行。
一个简单的测试结构可以这样写:
@Test
void shouldDenyWhenUserHasNoProjectRelation() {
mockLoginUser(2002L);
assertThrows(AccessDeniedException.class, () -> {
projectService.getDetail(1001L);
});
}对于数据权限类问题,测试数据要保持清晰,不要复用过多共享状态。否则后续维护时很难判断失败是权限逻辑问题,还是测试数据污染。
容易忽略的注意点
不要只依赖网关:网关适合做认证、粗粒度授权、限流,不适合承载复杂业务数据权限。
不要把用户 ID 从请求参数传入:当前用户应来自可信登录上下文,而不是前端参数。
缓存要带权限维度:如果项目详情被缓存,缓存 key 只包含项目 ID,可能导致不同权限用户拿到同一份数据。
导出和下载要单独审计:很多系统页面接口修了,但导出接口仍然缺少数据范围。
错误信息保持克制:对外不一定要区分“无权限”和“不存在”,避免资源枚举风险。
总结
接口越权不是某个框架特有的问题,本质是功能权限和数据权限没有分层设计。审计时不要只看注解里有没有 @PreAuthorize,还要继续追到服务层和 SQL 层,确认资源是否和当前用户建立了约束关系。
实际修复时,建议把“当前用户能访问哪些资源”抽象成统一的数据权限能力,而不是在每个接口里零散拼判断。对于详情、导出、下载、批量操作这些高频风险点,最好形成固定审计清单和回归测试。这样后续业务迭代时,权限问题不会反复出现。
推荐意见