一、事故现场:订单接口被遍历,用户数据裸奔
某电商中台上线后接到用户投诉:A 用户登录后,把订单详情接口 URL 里的订单号改成别人的订单号,居然能查到对方完整的收货地址与手机号。这不是偶发 bug,而是典型的 IDOR(Insecure Direct Object Reference,不安全的直接对象引用) 漏洞——服务端直接信任了客户端传过来的资源 ID,却没有校验「这个资源属于谁」。
先看最原始的漏洞代码:
@RestController
@RequestMapping("/api/order")
public class OrderController {
@GetMapping("/{id}")
public OrderVO detail(@PathVariable("id") Long id) {
// 漏洞:只按 id 查询,没有校验订单归属
return orderService.getById(id);
}
}
攻击者只需用 Burp Suite 抓包,把 id 从 1001 改成 1002、1003……就能批量拉取任意订单。如果 id 还是自增主键,遍历成本几乎为零,甚至可以写脚本把全站订单「爬」一遍。这就是水平越权最常见的样子。
二、分清两类越权:水平与垂直
- 水平越权(IDOR):同级别用户互相访问。普通用户 A 访问普通用户 B 的订单、购物车、优惠券。
- 垂直越权:低权限访问高权限功能。普通用户直接调用管理员的删除商品、修改价格接口。
两类漏洞的根因一致:服务端未对「当前登录身份」与「被访问资源」做关联校验。下面按从易到难的顺序给出四层防线。
三、第一层:属主校验,查询条件必须带用户维度
最直接的修法:所有涉及用户私有数据的查询,SQL 中强制拼接 user_id 条件,而不是查出数据后再在内存里判断。后者虽然也能拦截,但会先把数据读出来,既浪费资源,又容易在复杂分支中漏判。
@GetMapping("/{id}")
public OrderVO detail(@PathVariable("id") Long id) {
Long userId = currentUserId(); // 从登录态获取,绝不信任客户端传参
Order order = orderMapper.selectByIdAndUserId(id, userId);
if (order == null) {
// 统一返回 404,避免泄露「订单存在但属于别人」的信息
throw new ResourceNotFoundException("订单不存在");
}
return OrderConverter.toVO(order);
}
<select id="selectByIdAndUserId" resultType="com.example.order.domain.Order">
SELECT * FROM t_order
WHERE id = #{id} AND user_id = #{userId}
</select>
要点:查不到就返回 404,而不是 403。403 等于告诉攻击者「这个订单存在但不属于你」,反而辅助其枚举有效订单号。
四、第二层:身份从令牌取,禁止客户端传 userId
一个高频坑:很多接口为了省事,允许前端把 userId 放在请求参数或 Header 里,服务端直接用。这在登录态可被伪造的场景下等于没有鉴权。正确做法是统一从 Token 中解析:
public Long currentUserId() {
// 以 Spring Security 为例:身份信息在拦截器阶段已写入上下文
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof LoginUser loginUser)) {
throw new UnauthorizedException("未登录");
}
return loginUser.getUserId();
}
关键原则:谁登录、以什么身份操作,只由服务端说了算。前端传 userId 只是为了展示,服务端必须忽略并用自己的解析结果覆盖。
五、第三层:AOP 注解,把校验做成通用能力
业务接口一多,每个方法手写「查归属 → 比对」会重复且容易漏。用自定义注解加 AOP 切面统一处理:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireOwnership {
/** 资源类型,用于区分订单/购物车/优惠券等 */
String resource();
/** 资源 ID 在方法参数中的 SpEL 表达式 */
String resourceId();
}
@Aspect
@Component
public class OwnershipAspect {
@Around("@annotation(ro)")
public Object check(ProceedingJoinPoint pjp, RequireOwnership ro) throws Throwable {
Long userId = currentUserId();
// 通过 SpEL 从方法入参解析出资源 ID
Long resourceId = parseSpel(ro.resourceId(), pjp.getArgs());
boolean owned = ownershipChecker.isOwned(ro.resource(), resourceId, userId);
if (!owned) {
throw new ResourceNotFoundException("资源不存在");
}
return pjp.proceed();
}
}
@GetMapping("/{id}")
@RequireOwnership(resource = "order", resourceId = "#id")
public OrderVO detail(@PathVariable("id") Long id) {
// 切面已校验归属,这里只需按 id 查询
return orderService.getById(id);
}
这样新接口只要加一个注解就自动获得越权防护,团队规范也容易落地——审代码时看到私有数据接口没有这个注解,直接打回。
六、第四层:列表/批量接口的数据权限拦截
单体接口好防,真正容易翻车的是「列表、批量、子资源、文件下载」这几类:
- 列表接口:按 status 或关键字查询,SQL 里漏了 user_id 条件,一次返回全站数据。
- 子资源:订单详情防住了,但「订单项列表 /api/order/{id}/items」没防,还是能绕过去。
- 文件下载:对账单、发票 PDF 用 fileId 直下,没有校验文件归属。
- 批量接口:batchGet 传入 id 数组,循环查询时漏了用户维度。
对于列表类查询,最稳妥的是在 MyBatis 拦截器 层做兜底:拦截 SELECT 语句,自动拼接当前用户的数据权限条件(例如统一追加 AND user_id = ?),防止开发者在写复杂 XML 时漏掉。注意拦截器要做到「配置化 + 白名单表」,只对声明了数据权限的表生效,避免误伤字典表等公共数据。
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare",
args = {Connection.class, Integer.class})
})
public class DataScopeInterceptor implements Interceptor {
// 思路:解析 BoundSql,命中 dataScope 表时改写 SQL 追加 user_id 条件
// 具体实现需处理子查询、JOIN 别名等边界,线上务必用 EXPLAIN 验证改写后执行计划
}
核心是先有规范再用拦截器兜底,拦截器是安全网,不是偷懒的借口。
七、踩坑记录:五个自以为修好了的假象
- 只在前端隐藏按钮。接口裸奔,抓包直接调,前端隐藏等于没有。
- 只校验了「登录」没校验「归属」。登录是垂直边界,归属是水平边界,两码事。
- 缓存键没带用户维度。订单缓存用
order:{id},用户 A 的请求写进去,用户 B 的请求读出来,越权从漏洞变成了事故。 - 错误信息太「贴心」。返回「该订单属于用户 8888」,等于主动帮攻击者做数据字典。
- 只防了主资源。订单防了、订单项没防,功亏一篑。
八、自测清单与上线检查
1. 登录两个账号 A/B,用 A 的 Token 访问 B 的资源 ID,期望 404/403;
2. Burp 抓包遍历 id=1,2,3...,观察响应是否随 id 变化泄露他人数据;
3. 检查所有 GET/POST 参数与 Header 中是否允许自定义 userId;
4. 列表接口翻页到第 2/3 页,确认结果仍只含当前用户数据;
5. 文件下载、导出、批量接口逐一点检归属校验;
6. 用低权限账号直接调用管理端 URL,确认垂直越权被拦截。
越权漏洞的修复成本极低,但泄露后果极重——尤其是电商这类天然带着手机号、地址、支付信息的系统。把「属主校验」写进接口开发规范,用注解 + 拦截器做成默认能力,比事后救火划算得多。