跳转到帖子

背景

越权问题在 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,可按合规要求脱敏
actionread、update、delete、download
resultallowed 或 denied
request_id链路追踪 ID,便于排查

对于短时间内大量无权限访问、连续访问不存在资源 ID 等行为,可以做风险告警。但告警策略要结合业务实际,避免正常误操作触发过多噪音。

常见误区

  • 只校验 token:token 只能证明身份,不能证明资源权限。
  • 只靠前端隐藏按钮:前端控制不是安全边界。
  • 认为 ID 不连续就安全:随机 ID 能降低枚举概率,但不能替代授权校验。
  • 管理员接口不做限制:后台系统同样需要角色和数据范围控制。
  • 列表接口安全就够了:详情、导出、下载、批量接口更容易遗漏。
  • 返回 404 就等于安全:关键要看是否真的没有返回越权数据。

总结

越权问题的本质是服务端没有把“身份”和“资源权限”绑定起来。做接口审计时,不要只看有没有登录校验,也不要只看权限注解,要继续追到数据查询条件和业务授权逻辑。

比较实用的检查思路是:先找所有接收资源 ID 的接口,再看查询、更新、删除、下载是否带上当前用户、租户或授权范围;最后对同模块的关联接口做横向复查。修复时尽量把资源级授权封装成统一能力,避免每个接口各写一套判断。

权限设计没有银弹,但有一条底线:任何来自客户端的资源标识,都不能默认可信。服务端必须根据当前身份重新计算可访问范围。
代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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