背景
越权问题在 Web 安全里不算新,但在真实业务中依然很常见。它不像 SQL 注入、命令执行那样有明显的危险函数特征,更多时候藏在业务流程、接口参数和权限模型之间。尤其是前后端分离、微服务拆分之后,一个接口是否真正做了资源归属校验,往往需要结合代码、数据表和调用链一起看。
这篇文章整理一次典型的接口越权问题复盘,重点放在如何定位、验证和修复这类问题。内容只讨论授权校验与安全设计,不涉及攻击利用或数据获取细节,适合做代码审计、接口安全测试和研发自查时参考。
问题场景
某业务系统存在用户资料、订单记录、工单附件等资源。前端页面正常情况下只展示当前登录用户自己的数据,但后端部分接口通过传入资源 ID 查询详情,接口形态类似:
GET /api/order/detail?id=10086开发侧的初始判断通常是:前端不会展示别人的 ID,用户也不知道其他资源 ID,所以风险不高。但从安全角度看,只要接口以客户端传入的资源标识作为查询依据,就必须在服务端验证当前用户是否有权限访问该资源。
这类问题常见于以下场景:
- 详情页接口只校验登录态,不校验资源归属。
- 列表接口做了用户过滤,但详情、导出、下载接口遗漏。
- 管理员、普通用户共用接口,只在前端控制按钮展示。
- 多租户系统只校验用户 ID,未校验 tenant_id。
- 附件下载接口通过 fileId 或 objectKey 直接取文件。
技术分析
越权一般可以分为水平越权和垂直越权。水平越权指同权限级别用户访问了其他用户资源;垂直越权指低权限用户调用了高权限功能。接口层面最核心的问题是:鉴权和授权被混在一起,系统只确认“你是谁”,没有确认“你能不能访问这个对象”。
以常见的 Spring Boot 业务代码为例,一个存在风险的实现大致如下:
@GetMapping("/order/detail")
public OrderDetailVO detail(@RequestParam Long id) {
return orderService.getDetail(id);
}Service 层如果直接按主键查询,也没有结合当前登录用户或租户信息进行约束,就会产生访问控制缺口:
public OrderDetailVO getDetail(Long id) {
Order order = orderMapper.selectById(id);
return convert(order);
}看起来代码很正常,也没有明显的危险 API,但问题在于查询条件只有 id。正确的逻辑应该至少包含资源归属或授权关系,例如 user_id、tenant_id、department_id、role_scope 等。
public OrderDetailVO getDetail(Long id, LoginUser currentUser) {
Order order = orderMapper.selectOne(
Wrappers.<Order>lambdaQuery()
.eq(Order::getId, id)
.eq(Order::getUserId, currentUser.getUserId())
.eq(Order::getTenantId, currentUser.getTenantId())
);
if (order == null) {
throw new BizException("资源不存在或无权限访问");
}
return convert(order);
}这里有一个细节:错误提示不建议区分“资源不存在”和“无权限”,否则可能造成资源枚举辅助信息泄露。对外统一返回即可,内部日志可以记录更详细的审计信息。
审计切入点
代码审计时,不建议只按控制器文件逐个看,这样容易遗漏。更有效的方法是按“资源对象”和“操作类型”建立检查清单。
1. 按接口类型筛选高风险点
优先关注带有以下特征的接口:
- 参数中包含 id、userId、orderId、fileId、taskId、tenantId、projectId 等资源标识。
- 接口语义包含 detail、info、download、export、delete、update、approve、assign。
- 控制器方法中只依赖 @RequestParam 或 @PathVariable 查询对象。
- 接口只使用登录注解或 token 校验,没有权限注解或业务校验。
- 前端隐藏入口,但接口本身没有后端校验。
可以用简单的代码搜索辅助定位,例如:
grep -R "@GetMapping\|@PostMapping\|@PutMapping\|@DeleteMapping" src/main/java
grep -R "selectById\|getById\|findById" src/main/java
grep -R "download\|export\|detail\|delete\|update" src/main/java这些命令只是辅助发现入口,不能替代人工判断。真正要确认的是:查询资源时是否带上了当前用户可访问范围。
2. 检查数据权限是否落到服务端
很多系统有菜单权限、按钮权限,但这些并不等同于数据权限。菜单权限解决“能不能进入页面”,按钮权限解决“能不能看到操作入口”,数据权限解决“能不能操作这条数据”。
典型风险是前端路由和按钮控制做得很细,但后端接口只检查登录态。例如:
@PreAuthorize("hasAuthority('order:view')")
@GetMapping("/order/detail")
public OrderDetailVO detail(@RequestParam Long id) {
return orderService.getDetail(id);
}这里的 order:view 只能说明用户有查看订单功能,不代表用户可以查看任意订单。仍然需要在业务查询中约束数据范围。
3. 多租户系统重点看 tenant_id
多租户场景下,只按 user_id 校验通常不够。比如运营后台、代理商后台、企业空间系统中,一个用户可能属于某个租户或组织。所有核心表查询都应考虑 tenant_id 或组织边界。
较稳妥的方式是在数据访问层统一追加租户条件,同时在敏感操作中做显式校验,避免某些手写 SQL、复杂 JOIN 或历史接口绕开统一拦截器。
SELECT *
FROM orders
WHERE id = #{id}
AND tenant_id = #{tenantId}
AND deleted = 0如果存在跨租户管理员,也应该通过明确的角色和授权范围实现,而不是在代码里简单判断“管理员不加 tenant_id”。后者后期很容易扩大权限边界。
关键验证步骤
在授权范围内做安全测试时,可以按照以下流程验证接口是否存在越权风险。这里的重点是验证设计缺陷,而不是获取或扩散敏感数据。
1. 准备两个低权限测试账号
尽量使用同角色、同租户或不同租户的测试账号,分别创建各自的测试数据。不要使用真实用户数据。测试数据最好有明显标识,便于审计日志回溯。
2. 对比正常请求
分别登录账号 A 和账号 B,访问自己的资源详情接口,记录必要的请求结构,例如路径、方法、参数位置、鉴权头、响应状态。
GET /api/order/detail?id=1001
Authorization: Bearer <test-token-a>3. 替换资源标识进行权限验证
在账号 A 的登录态下,尝试访问账号 B 的测试资源 ID。安全的结果应该是返回无权限、资源不存在或空结果,且响应中不包含 B 的资源内容。
GET /api/order/detail?id=2001
Authorization: Bearer <test-token-a>如果接口返回了完整详情,就说明后端授权校验缺失。验证到此即可,不需要继续批量测试或扩大数据范围。
4. 检查关联接口
一个详情接口有问题时,同模块的导出、附件、删除、修改接口通常也要一起看。常见遗漏点包括:
- 附件下载接口只校验 fileId,不校验文件所属业务对象。
- 导出接口复用了列表查询,但未继承数据范围条件。
- 删除接口按 id 直接删除,没有判断创建人或租户。
- 审批接口只校验流程节点,不校验当前处理人。
- 批量操作接口只校验部分 ID,未对全部 ID 做权限过滤。
修复建议
越权问题的修复不能只靠前端隐藏参数,也不建议只在控制器里零散添加判断。更推荐按资源模型建立统一的授权策略。
1. 查询时绑定当前主体
最直接的修复方式是在查询条件中绑定当前用户、租户或授权范围。对于普通用户资源,通常是 user_id + tenant_id;对于部门数据,可能是 dept_id 或数据权限范围。
Order order = orderMapper.selectOne(
Wrappers.<Order>lambdaQuery()
.eq(Order::getId, id)
.eq(Order::getTenantId, currentUser.getTenantId())
.eq(Order::getUserId, currentUser.getUserId())
);2. 对修改类操作使用“带权限条件的更新”
更新和删除不建议先查再改后完全依赖内存判断,尤其在并发场景下更容易出问题。可以把权限条件带入 UPDATE 或 DELETE 条件中,并检查影响行数。
UPDATE orders
SET status = #{status}, updated_at = NOW()
WHERE id = #{id}
AND user_id = #{currentUserId}
AND tenant_id = #{tenantId}如果影响行数为 0,对外统一返回无权限或资源不存在。
3. 建立资源级授权函数
当业务规则复杂时,可以封装资源级授权函数,避免每个接口重复写散落逻辑。
public void checkOrderReadable(Long orderId, LoginUser user) {
boolean allowed = orderMapper.existsReadableOrder(
orderId,
user.getUserId(),
user.getTenantId(),
user.getRoleScope()
);
if (!allowed) {
throw new BizException("资源不存在或无权限访问");
}
}这类函数要注意命名清晰,例如 readable、editable、deletable、approvable,不同操作的权限边界可能不同,不能简单共用一个“hasAccess”。
4. 附件和对象存储要校验业务归属
附件下载是越权高发点。不要只根据 fileId 或 objectKey 生成下载地址,应先确认文件关联的业务对象归当前用户可访问。
FileRecord file = fileMapper.selectById(fileId);
if (file == null) {
throw new BizException("文件不存在或无权限访问");
}
permissionService.checkBusinessReadable(
file.getBizType(),
file.getBizId(),
currentUser
);
return storageService.createDownloadUrl(file.getObjectKey());如果使用临时签名 URL,也要控制有效期,并避免把永久 objectKey 暴露给前端长期复用。
日志与监控
越权修复后,建议补充必要的安全日志。日志不应记录完整敏感数据,但应记录谁在什么时候访问了什么资源、结果如何。
| 字段 | 建议内容 |
|---|---|
| user_id | 当前登录用户 ID |
| tenant_id | 当前租户或组织 ID |
| resource_type | 资源类型,如 order、ticket、file |
| resource_id | 资源 ID,可按合规要求脱敏 |
| action | read、update、delete、download |
| result | allowed 或 denied |
| request_id | 链路追踪 ID,便于排查 |
对于短时间内大量无权限访问、连续访问不存在资源 ID 等行为,可以做风险告警。但告警策略要结合业务实际,避免正常误操作触发过多噪音。
常见误区
- 只校验 token:token 只能证明身份,不能证明资源权限。
- 只靠前端隐藏按钮:前端控制不是安全边界。
- 认为 ID 不连续就安全:随机 ID 能降低枚举概率,但不能替代授权校验。
- 管理员接口不做限制:后台系统同样需要角色和数据范围控制。
- 列表接口安全就够了:详情、导出、下载、批量接口更容易遗漏。
- 返回 404 就等于安全:关键要看是否真的没有返回越权数据。
总结
越权问题的本质是服务端没有把“身份”和“资源权限”绑定起来。做接口审计时,不要只看有没有登录校验,也不要只看权限注解,要继续追到数据查询条件和业务授权逻辑。
比较实用的检查思路是:先找所有接收资源 ID 的接口,再看查询、更新、删除、下载是否带上当前用户、租户或授权范围;最后对同模块的关联接口做横向复查。修复时尽量把资源级授权封装成统一能力,避免每个接口各写一套判断。
权限设计没有银弹,但有一条底线:任何来自客户端的资源标识,都不能默认可信。服务端必须根据当前身份重新计算可访问范围。
推荐意见