跳转到帖子

背景

接口越权是业务系统里非常常见、也最容易被低估的一类问题。它通常不依赖复杂漏洞链,也不需要特殊环境,根因往往是后端只做了登录校验,却没有把“当前用户是否有权访问当前资源”这件事落实到每一个关键接口。

这篇文章整理一次比较典型的接口越权复盘思路,重点放在代码审计和修复验证上。内容面向企业内部安全自查、研发排查和安全测试复盘,不涉及非法访问或攻击引导。

问题场景

假设一个业务系统中存在订单、工单、客户资料等资源。前端页面会根据登录用户展示自己的数据,但后端接口通过资源 ID 查询详情时,只检查了用户是否登录,没有校验资源归属关系。

常见接口形态如下:

GET /api/order/detail?id=10086
GET /api/ticket/detail?ticketId=20240001
POST /api/customer/update

问题代码通常并不显眼,甚至在业务逻辑上看起来“很正常”:

@GetMapping("/detail")
public OrderDetailVO detail(@RequestParam Long id) {
    return orderService.getDetail(id);
}

如果 orderService.getDetail(id) 内部只是按照主键查询:

public OrderDetailVO getDetail(Long id) {
    Order order = orderMapper.selectById(id);
    return convert(order);
}

那么这个接口的实际权限边界就变成了“知道资源 ID 即可查询”,而不是“只能查询属于自己的资源”。

技术分析

接口越权一般可以分为两类:

  • 水平越权:同一权限级别用户之间互相访问资源,例如用户 A 查看用户 B 的订单。
  • 垂直越权:低权限用户访问高权限功能,例如普通用户调用管理员接口。

在代码审计时,水平越权更容易被遗漏,因为接口往往没有明显的角色差异,开发人员会认为“登录后就能访问”。但从安全角度看,登录认证只回答了“你是谁”,授权校验需要继续回答“你能不能操作这个对象”。

比较可靠的判断方式是观察关键查询条件中是否包含当前登录主体的约束,例如 user_id、tenant_id、org_id、owner_id 等。

接口类型高风险写法推荐写法
详情查询where id = ?where id = ? and user_id = ?
列表查询where status = ?where status = ? and tenant_id = ?
修改操作update by id先校验归属,再更新
删除操作delete by idwhere id = ? and owner_id = ?

代码审计切入点

实际审计中,不建议只盯 Controller 层。很多项目在 Controller 看不到权限判断,可能是放在拦截器、AOP、Service 或 SQL 层。建议按以下顺序排查。

一、梳理敏感资源接口

优先关注这些接口:

  • 订单、账单、合同、发票、客户资料等涉及业务数据的详情接口。
  • 用户资料、地址、手机号、邮箱等个人信息接口。
  • 文件下载、附件预览、导出接口。
  • 审批、工单、任务流转接口。
  • 后台管理系统中的编辑、删除、启停用接口。

可以通过路由、Swagger、网关日志或代码搜索快速定位:

grep -R "@GetMapping\|@PostMapping\|@RequestMapping" ./src/main/java

grep -R "selectById\|getById\|deleteById\|updateById" ./src/main/java

这些关键词不是漏洞本身,但能帮助快速找到“按 ID 操作资源”的位置。

二、检查当前用户上下文是否参与授权

常见的登录用户上下文获取方式包括:

Long userId = LoginContext.getUserId();
Long tenantId = LoginContext.getTenantId();
UserPrincipal principal = SecurityContext.getCurrentUser();

审计时要重点确认这些信息是否真正参与了数据库查询或业务判断,而不是只在日志里打印、只用于前端展示。

一个相对安全的详情查询示例:

public OrderDetailVO getDetail(Long orderId) {
    Long userId = LoginContext.getUserId();

    Order order = orderMapper.selectOne(
        Wrappers.<Order>lambdaQuery()
            .eq(Order::getId, orderId)
            .eq(Order::getUserId, userId)
            .eq(Order::getDeleted, 0)
    );

    if (order == null) {
        throw new BizException("资源不存在或无权限访问");
    }

    return convert(order);
}

这里有一个细节:错误信息建议统一返回“资源不存在或无权限访问”,避免通过差异化提示暴露资源是否存在。

三、关注多租户和组织边界

B 端系统里,越权问题不一定发生在 user_id 维度,更多时候发生在 tenant_id、company_id、department_id、project_id 上。

例如以下写法只校验了当前用户已登录,但没有限制租户:

public CustomerVO getCustomer(Long customerId) {
    Customer customer = customerMapper.selectById(customerId);
    return convert(customer);
}

在多租户系统中,更稳妥的写法是:

public CustomerVO getCustomer(Long customerId) {
    Long tenantId = LoginContext.getTenantId();

    Customer customer = customerMapper.selectOne(
        Wrappers.<Customer>lambdaQuery()
            .eq(Customer::getId, customerId)
            .eq(Customer::getTenantId, tenantId)
    );

    if (customer == null) {
        throw new BizException("资源不存在或无权限访问");
    }

    return convert(customer);
}

如果项目使用了 MyBatis Plus 的租户插件,也不能完全依赖框架默认行为。需要确认以下几点:

  • 是否所有表都正确配置了 tenant_id。
  • 是否存在忽略租户过滤的特殊 Mapper。
  • 自定义 SQL 是否绕开了自动拼接条件。
  • 批量导出、统计报表、异步任务是否同样受租户限制。

四、不要只校验前端传入的 userId

比较常见的错误是接口参数里带 userId,然后后端直接信任:

@GetMapping("/list")
public List<OrderVO> list(@RequestParam Long userId) {
    return orderService.listByUserId(userId);
}

如果这是普通用户接口,userId 应该来自服务端登录态,而不是客户端参数。客户端传来的 userId 只能作为筛选条件的一部分,不能作为身份依据。

建议改为:

@GetMapping("/list")
public List<OrderVO> list() {
    Long userId = LoginContext.getUserId();
    return orderService.listByUserId(userId);
}

对于管理员接口,如果确实需要查询指定用户数据,也应该额外进行角色和数据范围校验,例如只能查看本部门、本租户或被授权范围内的数据。

五、修改和删除接口更容易出问题

很多团队会在详情接口上补权限,但忘记修改、删除、状态变更接口。实际上这类接口影响更大。

不推荐写法:

public void cancelOrder(Long orderId) {
    Order order = orderMapper.selectById(orderId);
    if (order == null) {
        throw new BizException("订单不存在");
    }
    order.setStatus(OrderStatus.CANCELLED);
    orderMapper.updateById(order);
}

推荐做法是先基于当前用户约束查询,再执行业务变更:

public void cancelOrder(Long orderId) {
    Long userId = LoginContext.getUserId();

    Order order = orderMapper.selectOne(
        Wrappers.<Order>lambdaQuery()
            .eq(Order::getId, orderId)
            .eq(Order::getUserId, userId)
    );

    if (order == null) {
        throw new BizException("资源不存在或无权限操作");
    }

    if (!OrderStatus.canCancel(order.getStatus())) {
        throw new BizException("当前状态不允许取消");
    }

    order.setStatus(OrderStatus.CANCELLED);
    orderMapper.updateById(order);
}

如果是高并发场景,可以考虑把归属校验和状态条件放到同一个 update 语句里,避免查询后状态变化导致的竞态问题:

int rows = orderMapper.update(null,
    Wrappers.<Order>lambdaUpdate()
        .set(Order::getStatus, OrderStatus.CANCELLED)
        .eq(Order::getId, orderId)
        .eq(Order::getUserId, userId)
        .eq(Order::getStatus, OrderStatus.WAIT_PAY)
);

if (rows == 0) {
    throw new BizException("资源不存在、无权限或状态已变化");
}

六、文件下载接口要单独审计

文件类接口经常被当作“静态资源”处理,但附件本身可能属于某个订单、工单或用户。如果下载接口只根据 fileId 返回文件,就存在越权风险。

高风险示例:

@GetMapping("/file/download")
public void download(@RequestParam Long fileId, HttpServletResponse response) {
    FileInfo file = fileService.getById(fileId);
    fileStorage.writeToResponse(file.getPath(), response);
}

更合理的方式是建立文件与业务对象的授权关系:

public void download(Long fileId, HttpServletResponse response) {
    Long userId = LoginContext.getUserId();

    FileInfo file = fileMapper.selectAuthorizedFile(fileId, userId);
    if (file == null) {
        throw new BizException("文件不存在或无权限访问");
    }

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

同时建议避免直接暴露服务器真实路径,文件存储 key 与原始文件名也要做好隔离。

验证思路

在授权测试中,建议使用正常测试账号和授权测试数据进行验证,不要访问未授权系统或真实用户数据。一个基本验证流程如下:

  • 准备两个权限相同但数据不同的测试账号 A、B。
  • 使用账号 A 创建或查看一条测试资源,记录资源 ID。
  • 切换到账号 B,在合法测试范围内请求同类接口。
  • 确认后端是否返回统一的无权限响应,而不是资源内容。
  • 对详情、列表、修改、删除、下载、导出接口分别验证。

如果项目有自动化测试基础,可以把越权用例加入接口测试。示例伪代码如下:

@Test
void userShouldNotReadOthersOrder() {
    Long orderIdOfUserA = createOrder(userA);

    HttpResponse response = requestAs(userB)
        .get("/api/order/detail?id=" + orderIdOfUserA);

    assertThat(response.status()).isIn(403, 404);
    assertThat(response.body()).doesNotContain("orderAmount");
}

这里不强制要求一定返回 403。很多系统会统一返回 404 或业务错误码,关键是不能返回资源内容,也不能通过响应差异暴露过多信息。

常见修复误区

  • 只在前端隐藏按钮:前端控制只能改善体验,不能作为安全边界。
  • 只检查是否登录:认证不等于授权。
  • 只修详情接口:修改、删除、导出、下载同样需要校验。
  • 过度依赖网关:网关适合做粗粒度权限,资源归属通常需要业务层判断。
  • 错误信息过于具体:例如“订单存在但不是你的”,容易辅助枚举资源。
  • 管理员接口没有数据范围:管理员也应受到租户、组织、角色范围限制。

落地建议

如果要在团队内系统性降低越权问题,建议从工程规范入手,而不是只靠单点修补。

  • 为核心资源定义统一的 ownership 字段,例如 user_id、tenant_id、owner_id。
  • Service 层封装授权查询方法,避免各处直接 selectById。
  • 新增接口评审时,必须回答“资源归属如何校验”。
  • 对导出、下载、批量操作建立单独检查清单。
  • 在测试环境保留多账号、多租户数据,用于回归验证。
  • 将越权测试用例纳入自动化接口测试或安全回归测试。
经验上,越权问题很少是某一个接口的孤立失误,更多是项目缺少统一授权模型。代码审计时不要只找一个点,要沿着同类资源和同类操作横向展开。

总结

接口越权的本质是授权边界缺失。修复时不能停留在“加一个登录判断”或“前端隐藏入口”,而应在后端基于当前登录主体、资源归属、租户组织、角色范围进行完整校验。

做代码审计时,可以围绕“按 ID 查询或操作资源”的接口展开,重点检查详情、修改、删除、下载、导出等高价值路径。真正可靠的做法,是让每一次资源访问都带上服务端可信的身份约束,并通过测试用例持续验证。

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

0篇意见

推荐意见

没有意见。

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