越权不是“没登录”
接口鉴权最常见的漏洞不是匿名访问,而是登录了、也能通过身份校验,但访问了不属于自己的数据。典型场景:把 /api/orders/10086 改成 /api/orders/10087,只要对方订单存在,接口就把完整信息返回了。
这类问题在安全领域叫 IDOR(不安全的直接对象引用)。它之所以高发,是因为框架的鉴权只做到“是否登录”“是否有角色”,而“这条数据属于谁”属于业务语义,框架管不到。
三类越权与 ID 枚举
水平越权:同角色用户之间互相访问。用户 A 能看用户 B 的订单、地址、账单。
垂直越权:低权限角色调用高权限接口。普通用户访问 /api/admin/export,如果只在网关做了登录校验、没校验角色,接口就直接执行了。
上下文越权:跨租户、跨门店、跨组织。多租户系统里,同一个用户在有权限的两个租户之间切换,如果不校验当前租户,就会出现“切到 A 租户,看到 B 租户的数据”。
枚举成本也要考虑。自增 ID 让攻击者从 1 开始遍历,一天能扫完全表。用随机 ID 或加不可猜测的盐能提高成本,但这只是纵深防御,不是越权防御——ID 一旦泄露(分享链接、截图、日志),防护就没了。
在数据访问层兜底
“先按 ID 查出来,再判断归属”是最常见的写法,也是最容易漏的写法:多一次查询、多一个判断分支、多一个被后来者删掉的机会。而且它天然会泄露资源是否存在——判断分支里返回 403,等于告诉对方“这条数据存在,只是你没权限”。
更稳的做法是把归属条件下推到 SQL,让数据库来判断“你是否有权看到这条记录”。查不到就返回 404:
<select id="selectByIdAndUserId" resultMap="OrderResultMap">
SELECT id, user_id, status, amount, create_time
FROM t_order
WHERE id = #{orderId}
AND user_id = #{userId}
</select>
@Service
public class OrderQueryService {
private final OrderMapper orderMapper;
public OrderQueryService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public OrderDetail detail(Long orderId, Long currentUserId) {
Objects.requireNonNull(orderId, "orderId 不能为空");
Objects.requireNonNull(currentUserId, "currentUserId 不能为空");
OrderDO order = orderMapper.selectByIdAndUserId(orderId, currentUserId);
if (order == null) {
// 与“不存在”返回同样的结果,避免泄露资源是否存在
throw new BizException(ErrorCode.ORDER_NOT_FOUND, "订单不存在");
}
return OrderDetail.from(order);
}
}
currentUserId 必须来自令牌,绝不能来自请求参数。批量接口要逐条校验,不能只看数组第一个元素——这是线上真实出现过的高危写法。导出、下载这类接口也一样:导出的查询条件如果由前端传入,就等于把数据范围的决定权交给了客户端。
排查清单
上线前用两个不同账号互测,是发现越权最有效的手段,比任何扫描器都直接:
- 列出所有路径里带资源 ID 的接口,包括详情、编辑、删除、导出、下载、分享。
- 用账号 A 的令牌访问账号 B 的资源,逐个确认返回 404 或 403。
- 用低权限账号遍历管理端接口路径,确认不是靠前端隐藏菜单来保护。
- 检查批量接口的每个 ID 是否都做了归属校验。
- 检查文件下载链接是否带签名与有效期,能否被直接转发。
- 检查缓存 key 是否只用了资源 ID,导致 A 的请求命中了 B 的缓存。
线上还可以用行为特征兜底:单个账号在短时间内产生大量 404、同一令牌访问跨越多个用户的资源、异常的资源 ID 递增序列,都是扫描的典型特征。这类规则应该接进风控和告警,而不是只写进日志。
# 用两个测试账号做对比验证,脚本化之后可以进 CI
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOKEN_A" \
"https://api.example.com/api/orders/$ORDER_ID_OF_B"
# 期望 404,返回 200 就是越权
常见坑
只在 Controller 校验。 同一个 Service 方法可能被 MQ 消费、定时任务、内部 RPC 复用,Controller 里那句校验在别的入口根本不会执行。
用自增 ID 又暴露业务量。 /api/orders/10086 一眼能看出单量,还方便枚举。至少在对外接口换成不可猜测的 ID。
403 和 404 混着返回。 一方面泄露存在性,另一方面让攻击者能准确区分“没权限”和“不存在”,枚举效率更高。
管理端和用户端共用一个网关鉴权。 网关只知道“已登录”,不知道“是管理员”。管理端接口必须有独立的鉴权链和独立的权限点。
前端隐藏按钮当成防护。 菜单藏起来只是体验,接口还在,抓个包就能调。
缓存跨用户命中。 为了性能把详情缓存成 order:{id},如果缓存里存的是包含用户信息的对象,一旦 key 设计不当或被共享,就会把 A 的数据返回给 B。
评论