跳转到帖子

背景

接口鉴权问题在代码审计里非常常见,但实际危害往往被低估。很多系统表面上有登录态、网关鉴权、角色菜单控制,真正落到业务接口时却只做了“是否登录”的判断,没有验证当前用户是否有权限访问目标资源。

这类问题不一定表现为传统意义上的漏洞利用链,但在真实业务中影响很直接:越权查看订单、修改他人资料、导出不属于自己的数据,甚至触发后台流程。本文整理一次典型接口鉴权缺陷的审计思路,重点放在如何发现、确认和修复,避免提供攻击性操作引导。

问题场景

审计对象是一个常见的 Java Web 后台系统,技术栈大致如下:

  • Spring Boot + Spring MVC
  • JWT 登录态
  • MyBatis 访问数据库
  • 前端基于菜单权限展示不同页面
  • 部分接口在网关层校验 Token 是否有效

系统中存在多个资源型接口,例如订单、工单、客户资料、附件下载等。前端菜单权限做得比较完整,但后端部分接口只依赖前端传入的 ID 查询数据,没有绑定当前登录用户或组织范围。

经验上看,越权问题最容易出现在“详情查询、列表导出、附件下载、状态修改”这四类接口里。菜单权限只能控制入口,不能替代服务端资源级鉴权。

技术分析

审计时先看全局鉴权链路。系统使用拦截器解析 JWT,并把用户信息写入上下文:

public class AuthInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request,
                             HttpServletResponse response,
                             Object handler) {
        String token = request.getHeader("Authorization");
        LoginUser user = jwtService.parse(token);
        UserContext.set(user);
        return true;
    }
}

这段逻辑解决的是“用户是谁”,但没有解决“用户是否可以访问当前资源”。接下来需要沿着 Controller、Service、Mapper 一层层看业务接口是否做了资源归属校验。

一个有问题的接口大致类似下面这样:

@GetMapping("/order/detail")
public OrderDetailVO detail(@RequestParam Long orderId) {
    return orderService.getDetail(orderId);
}
public OrderDetailVO getDetail(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    return convert(order);
}

这里的风险点比较明显:接口只根据 orderId 查询订单,没有使用当前登录用户信息,也没有校验组织、租户、数据范围。只要调用者处于登录状态,就可能访问不属于自己的订单详情。

再看 Mapper 层:

<select id="selectById" resultType="Order">
    SELECT id, user_id, org_id, amount, status, created_at
    FROM t_order
    WHERE id = #{id}
</select>

SQL 也没有任何数据范围条件。此时不需要复杂测试,单从代码路径已经可以判断资源级鉴权缺失。

审计关键步骤

1. 梳理鉴权边界

首先明确系统有哪些鉴权点,常见包括:

  • 网关层:校验 Token、基础路由权限
  • 拦截器:解析用户身份、检查登录状态
  • 注解权限:如 @PreAuthorize、@RequiresPermissions
  • 业务层:校验数据归属、组织范围、角色可见范围
  • 数据库层:通过 tenant_id、org_id、user_id 限定查询范围

只要某类接口没有落到业务层或数据库层的数据范围控制,就需要重点关注。

2. 关注 ID 直查类接口

审计中可以优先搜索以下模式:

selectById(
getById(
findById(
detail(
download(
export(
updateStatus(
deleteById(

这些方法名本身不代表一定有问题,但通常意味着“客户端传入资源 ID,服务端直接操作资源”。如果没有结合当前用户上下文,就容易出现越权。

重点检查 Controller 是否接收了资源 ID:

@RequestParam Long id
@PathVariable Long id
@RequestBody XxxRequest request

然后跟进 Service 是否有类似判断:

LoginUser user = UserContext.get();
// 是否校验 userId / orgId / tenantId / role / dataScope

如果完全没有使用当前用户信息,基本就要标记为高风险点。

3. 区分功能权限和数据权限

很多系统会在方法上加权限注解,例如:

@PreAuthorize("hasAuthority('order:view')")
@GetMapping("/order/detail")
public OrderDetailVO detail(@RequestParam Long orderId) {
    return orderService.getDetail(orderId);
}

这只能说明当前用户拥有“查看订单”这个功能权限,并不代表他可以查看任意订单。功能权限和数据权限不是一回事。

比较稳妥的写法是把资源归属也纳入查询条件:

public OrderDetailVO getDetail(Long orderId) {
    LoginUser user = UserContext.get();
    Order order = orderMapper.selectVisibleOrder(
        orderId,
        user.getTenantId(),
        user.getOrgId(),
        user.getUserId()
    );
    if (order == null) {
        throw new BizException("资源不存在或无权限访问");
    }
    return convert(order);
}
<select id="selectVisibleOrder" resultType="Order">
    SELECT id, user_id, org_id, tenant_id, amount, status, created_at
    FROM t_order
    WHERE id = #{orderId}
      AND tenant_id = #{tenantId}
      AND (
          user_id = #{userId}
          OR org_id = #{orgId}
      )
</select>

实际项目中数据范围可能更复杂,例如部门树、项目成员、客户归属、审批参与人等,但原则一样:查询条件必须体现当前用户的可见范围。

4. 检查批量操作和导出接口

批量接口比详情接口更容易被忽略。例如:

@PostMapping("/order/export")
public void export(@RequestBody OrderQuery query,
                   HttpServletResponse response) {
    orderService.export(query, response);
}

如果 OrderQuery 中包含 orgId、userId、status、dateRange 等字段,后端不能直接信任前端传入的数据范围。正确做法是以后端用户上下文重新计算查询范围,而不是使用请求参数决定权限边界。

public List<Order> queryOrders(OrderQuery query) {
    LoginUser user = UserContext.get();
    DataScope scope = dataScopeService.buildScope(user);

    return orderMapper.queryOrders(query, scope);
}

导出接口还应增加审计日志和数量限制,避免因为一个鉴权缺陷放大成大规模数据泄露。

5. 附件下载接口单独处理

附件下载常被当成静态资源处理,但很多附件本质上属于业务对象,例如合同、工单截图、证件材料等。常见问题是文件表只根据 fileId 查询路径,然后直接输出文件。

public void download(Long fileId, HttpServletResponse response) {
    FileMeta file = fileMapper.selectById(fileId);
    fileStorage.writeToResponse(file.getPath(), response);
}

更安全的方式是附件表记录业务类型和业务 ID,下载时回到业务对象上做权限判断:

public void download(Long fileId, HttpServletResponse response) {
    FileMeta file = fileMapper.selectById(fileId);
    if (file == null) {
        throw new BizException("文件不存在");
    }

    permissionService.checkBusinessResource(
        file.getBizType(),
        file.getBizId(),
        UserContext.get()
    );

    fileStorage.writeToResponse(file.getPath(), response);
}

这里不建议把“文件是否存在”和“无权限”区分得太明显,避免泄露资源枚举信息。

修复建议

针对这类问题,单点修补通常不够,建议从以下几层一起改。

1. 建立统一的数据权限模型

不要在每个 Service 里临时拼权限判断。可以抽象出 DataScope 或 PermissionContext,统一描述当前用户可访问的数据范围。

public class DataScope {
    private Long tenantId;
    private Set<Long> visibleOrgIds;
    private Set<Long> visibleUserIds;
    private boolean admin;
}

不同业务模块使用同一套上下文,能减少漏判,也方便后续审计。

2. 查询时默认带租户和组织条件

多租户系统尤其要注意 tenant_id。很多事故不是因为没有角色权限,而是因为跨租户查询条件缺失。

WHERE tenant_id = #{scope.tenantId}
  AND org_id IN
  <foreach collection="scope.visibleOrgIds" item="orgId" open="(" close=")" separator=",">
      #{orgId}
  </foreach>

如果使用 MyBatis Plus、JPA 或自研 ORM,也可以考虑在拦截器层统一注入租户条件,但业务数据权限仍建议在业务层明确表达,避免隐式规则难以排查。

3. 修改类接口要先查权限再操作

状态修改、删除、审批等接口不能只校验目标 ID 是否存在。推荐流程是:

  • 根据当前用户和数据范围查询目标资源
  • 判断资源状态是否允许操作
  • 执行业务变更
  • 记录操作审计日志
public void cancelOrder(Long orderId) {
    LoginUser user = UserContext.get();
    Order order = orderMapper.selectVisibleOrder(
        orderId,
        user.getTenantId(),
        user.getOrgId(),
        user.getUserId()
    );
    if (order == null) {
        throw new BizException("资源不存在或无权限访问");
    }
    if (!order.canCancel()) {
        throw new BizException("当前状态不允许取消");
    }
    orderMapper.updateStatus(orderId, OrderStatus.CANCELED);
    auditLogService.record(user, "ORDER_CANCEL", orderId);
}

4. 增加自动化测试用例

越权问题很适合写集成测试。至少准备两个普通用户,分别创建资源,然后验证用户 A 不能访问用户 B 的资源。

@Test
void userShouldNotReadOthersOrder() {
    String tokenA = loginAs("userA");
    Long orderB = createOrderAs("userB");

    mockMvc.perform(get("/order/detail")
            .header("Authorization", tokenA)
            .param("orderId", String.valueOf(orderB)))
        .andExpect(status().isForbidden());
}

如果系统为了兼容返回 200 + 业务错误码,也应在测试里明确断言错误码,避免出现“查不到”和“没权限”逻辑混乱。

审计时的注意点

  • 不要只看 Controller 上有没有权限注解,还要看资源归属是否校验。
  • 不要信任前端传入的 userId、orgId、tenantId,这些字段只能作为查询条件的一部分,不能作为权限依据。
  • 列表接口要关注导出、分页、搜索条件组合,很多越权只在复杂筛选时出现。
  • 附件、消息、审批记录、操作日志也属于敏感资源,不应按普通静态文件处理。
  • 管理员角色要有明确边界,避免一个“后台管理员”默认拥有跨租户或全局数据权限。
  • 错误信息保持克制,不建议返回“该资源属于其他用户”这类提示。

风险分级参考

接口类型常见问题风险关注点
详情查询仅按 ID 查询敏感字段泄露
列表导出数据范围由前端传入批量数据泄露
状态修改只校验登录态越权变更业务流程
附件下载fileId 直查路径合同、证件、工单材料泄露
删除接口按 ID 直接删除数据破坏和审计缺失

总结

接口鉴权缺陷的核心不是“有没有登录”,而是“当前用户是否有权访问这个具体资源”。在代码审计中,建议围绕 ID 直查、批量导出、附件下载、状态修改这几类接口重点排查,沿着 Controller 到 Mapper 完整跟踪数据范围是否落地。

修复时不要依赖前端控制,也不要只补几个 if 判断。更稳妥的方案是建立统一数据权限模型,在查询和修改路径上都绑定当前用户上下文,同时用集成测试固化边界。这样后续业务迭代时,即使接口数量增加,也能降低同类问题反复出现的概率。

代码审计与漏洞定位示意图
代码审计、调用链与关键函数定位示意

0篇意见

推荐意见

没有意见。

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