我扫了项目里 739 个 Controller,发现最危险的越权漏洞不是没认证
最近做了一次项目内审,结果有点出乎我的意料。
先说好消息:网关层的认证做得很完整------token 校验、白名单、XSS 过滤、跨域配置,一个不少。所有接口都需要登录,匿名接口寥寥无几且都有明确注释。
坏消息是:认证没问题,鉴权全是洞。
一组吓人的数字
我用 grep 扫了整个后端 739 个 Controller 文件,统计了两个注解的出现次数:
@RequiresPermissions(功能权限):542 处@DataScope(数据权限):35 处
差了 15 倍。
什么意思呢?就是"你能不能进这个菜单"管得很严,但"进了菜单之后你能看哪些数据"几乎没人管。
一个最典型的例子
有个"学生详情"接口,路径长这样:
ini
GET /student/detail?studentId=123
接口上有 @RequiresPermissions("student:view"),功能权限是有的。但你把 URL 里的 studentId 改成任意一个数字,都能看到对应学生的全部信息------包括身份证号、家庭住址、家长手机号。
因为代码里是这么写的:
java
@GetMapping("/detail")
@RequiresPermissions("student:view")
public AjaxResult getInfo(Long studentId) {
Student student = studentService.selectStudentById(studentId);
return AjaxResult.success(student);
}
直接拿前端传进来的 studentId 去查库,完全没校验这个学生是不是当前登录用户管辖范围内的。
你是 A 班的老师,改一下 URL 里的 ID 就能看到 B 班 C 校 D 市的学生数据。
这就是典型的水平越权。
为什么会变成这样
我捋了一下,根因有三个:
1. 生成器的"遗产"
代码生成器出来的 CRUD Controller 都带权限注解,这没问题。但项目里手写的新模块、聚合接口、统计接口,是开发自己加的 Controller,这些大多漏了注解。
生成器管得了标准化的东西,管不了人手动写的。
2. 网关只认证、不鉴权
网关 Filter 只校验"token 有效吗",至于"这个用户能不能调这个接口、能看哪些数据",是各服务自己的注解说了算。
于是就出现了一个很讽刺的情况:网关把所有未登录的请求都拦住了,但登录之后你能调所有接口。
3. 习惯"记录谁改的",不习惯"校验是谁的"
代码里到处是 SecurityUtils.getUsername() 用来记操作人,但 getUserId() 用来校验数据归属的,少得可怜。
好像大家默认了"只要记录了是谁改的,就没问题"------但越权的问题恰恰是:他改了不该他改的东西,你只记录下来有什么用?
我是怎么扫出来的
方法很笨,一条 grep 命令的事:
bash
# 找「无权限注解的映射方法」:映射方法前 6 行内没有权限/匿名/内部注解的,全部列出
grep -rn -B6 "@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping" \
--include="*Controller.java" . \
| grep -v "RequiresPermissions\|RequiresRoles\|Anonymous\|InnerAuth" \
| grep "@.*Mapping"
逻辑很简单:每个 @XxxMapping 方法,往前看 6 行,如果没有任何权限相关的注解,就列出来。
扫出来多少个呢?87 个。
也就是说,整个项目里有 87 个接口是"只要登录就能调"的。
哪些最危险
不是所有漏注解的接口都一样危险,我按风险从高到低排了个序:
🔴 最高危:写操作无权限注解
- 生成 / 重算 / 审批 / 删除类的 POST/PUT/DELETE 接口
- 普通低权限账号一点击就能触发全量计算
- 这种必须最先补
🟡 中危:详情 / 修改 / 删除接口
- 直接拿前端传的 ID 查库/改库/删库
- 没有校验数据归属
- 改个 URL 参数就能越权
🟢 低危:列表查询接口
- 虽然可能看到全部数据,但至少没有写操作
- 补上数据权限注解就行
怎么修
分三层,从急到缓:
第一层:先把功能权限补上
所有没有 @RequiresPermissions 的接口,先按模块补上功能权限。87 个接口,按优先级从高到低补,写操作最先。
这一步能挡住"低权限账号调高级功能"的问题。
第二层:资源 ID 从登录态反查
这条最关键------不要直接信任前端传进来的资源 ID。
举个例子,老师看自己班上的学生,正确的逻辑是:
java
// 错误:直接用前端传的 studentId
Student student = studentService.selectStudentById(studentId);
// 正确:先拿登录用户的学校/班级范围,再查学生
Long teacherId = SecurityUtils.getUserId();
List<Long> classIds = teacherService.getClassIdsByTeacher(teacherId);
Student student = studentService.selectStudentByIdAndClassIds(studentId, classIds);
⚠️ 这里有个容易踩的坑:getUserId() 返回的是登录用户 ID ,不是业务资源 ID(学生 ID / 班级 ID)。直接 studentId = getUserId() 会引入 ID 混用的 bug,必须先反查绑定关系。
第三层:列表查询补数据权限
列表类的接口,统一加 @DataScope 注解或者在 SQL 里拼归属条件。这一步量最大,但做完之后整个数据层的权限就闭环了。
最后说两句
做这次审计之前,我也以为"网关有认证、接口有权限注解"就差不多安全了。
扫完之后才发现,认证和鉴权是两层,任何一层漏了都是洞。
- 认证 = 你是谁(网关层)
- 功能权限 = 你能不能调这个接口(服务层注解)
- 数据权限 = 你能看哪些行的数据(服务层注解 + SQL)
大多数团队都做到了前两层,第三层大面积遗漏。而越权事故,十有八九出在第三层。
自查清单,你的项目也可以跑一下:
- 所有 Controller 接口都有
@RequiresPermissions吗? - 详情 / 修改 / 删除接口校验数据归属了吗?
- 写操作(生成 / 重算 / 审批 / 删除)都有权限注解吗?
- 资源 ID 是从登录态反查,还是直接信任前端入参?
- 列表查询带数据权限过滤吗?