背景
最近在做一轮内部系统安全复盘时,遇到一个比较典型的问题:某业务后台的接口没有明显报错,也没有高危组件漏洞,但通过日志可以看到少量异常访问命中了用户信息查询接口。最后确认问题不是传统意义上的“未登录访问”,而是接口鉴权边界不清导致的水平越权。
这类问题在 Web 安全里很常见,尤其是前后端分离、微服务拆分后,很多接口默认依赖前端控制入口,后端只做了登录态校验,却没有做资源归属校验。本文整理一下排查思路、审计重点和修复建议,偏长期可复用的方法,不涉及真实目标攻击操作。
问题场景
业务形态大致如下:用户登录后可以查看自己的订单、资料、工单等信息,前端通过用户 ID、订单 ID 或工单 ID 调用后端 API。接口示例类似:
GET /api/order/detail?id=10086
Authorization: Bearer eyJxxx...表面上接口已经要求登录,并且 token 校验正常。但如果后端只判断“当前请求是否已登录”,没有判断“当前用户是否拥有该订单”,就会出现越权风险。
常见表现包括:
- 修改 URL 或请求体中的 id 参数后,返回了其他用户资源。
- 列表接口做了用户过滤,但详情接口没有过滤。
- 查询接口有权限判断,导出接口、下载接口遗漏。
- 管理端和用户端复用了同一服务方法,权限条件不一致。
- 网关做了统一认证,但业务服务没有做对象级授权。
技术分析
越权问题的核心不是“有没有登录”,而是“当前主体是否有权访问当前对象”。可以把权限判断拆成三层:
- 认证:请求者是谁,例如 session、JWT、SSO 登录态。
- 角色权限:请求者是否拥有该功能入口,例如普通用户、客服、管理员。
- 对象权限:请求者是否拥有该具体数据对象,例如订单、文件、工单、项目。
很多系统前两层做得还可以,第三层容易遗漏。原因通常是开发时默认认为接口参数来自前端可信来源,或者认为 ID 不可猜测就足够安全。实际上,ID 是否连续不是根本问题,即便是 UUID,只要某个 ID 泄露或被间接引用,缺少对象级授权仍然会造成数据暴露。
审计切入点
代码审计时,我一般从路由、控制器、服务层、数据访问层四个位置看。重点不是逐行找“漏洞关键词”,而是找权限条件是否在数据查询链路中持续存在。
1. 路由和注解
先看接口是否声明了认证和角色要求。例如 Spring 项目中常见注解:
@PreAuthorize("hasRole('USER')")
@GetMapping("/order/detail")
public OrderDetail detail(@RequestParam Long id) {
return orderService.detail(id);
}这段代码只能说明当前用户具备 USER 角色,并不能说明该用户可以访问 id 对应的订单。只靠角色注解是不够的。
2. 控制器是否传入当前用户上下文
如果控制器只把外部传入的资源 ID 传给服务层,没有传当前用户信息,后续服务层通常很难做对象级校验:
// 风险写法
public OrderDetail detail(Long id) {
return orderMapper.selectById(id);
}更稳妥的方式是从服务端可信上下文中获取当前用户 ID,并参与查询或校验:
// 推荐思路
public OrderDetail detail(Long id) {
Long currentUserId = SecurityContext.getCurrentUserId();
return orderMapper.selectByIdAndUserId(id, currentUserId);
}3. 数据查询条件是否绑定归属
越权修复不建议只在查询后做简单判断,优先让权限条件进入查询条件,减少误用和竞态问题。例如:
SELECT id, order_no, amount, status
FROM orders
WHERE id = #{id}
AND user_id = #{currentUserId}
AND deleted = 0如果是多租户系统,还要注意 tenant_id、project_id、org_id 等边界字段。常见修复是只加了 user_id,却漏了 tenant_id,导致同一用户在多个组织之间仍可能越界访问。
4. 复用方法的权限语义
服务方法被用户端、管理端、定时任务复用时,要特别小心。比如:
Order getOrderById(Long id)这个方法本身没有表达权限语义,很容易被误用。建议区分不同场景:
- getUserOrderDetail(orderId, currentUserId):用户侧,只能查自己的。
- getAdminOrderDetail(orderId, adminContext):管理侧,需要角色和数据范围。
- getOrderInternal(orderId):内部任务使用,限制调用范围。
权限语义写清楚,比依赖调用方“记得校验”更可靠。
关键排查步骤
下面是一套偏防守和审计视角的流程,适合在授权范围内做内部验证。
步骤一:梳理接口资产
优先整理包含资源 ID 的接口,尤其是详情、下载、导出、修改、删除类接口。可以从网关日志、OpenAPI 文档、路由配置中提取。
# 示例:从 nginx access log 中粗略提取 API 路径
awk '{print $7}' access.log | cut -d '?' -f 1 | sort | uniq -c | sort -nr | head -50重点关注这些参数名:
- id、userId、orderId、fileId、projectId
- tenantId、orgId、deptId、teamId
- owner、creator、account、mobile
步骤二:建立权限矩阵
不要只看接口能不能访问,要建立“主体-功能-对象”的矩阵。例如:
| 主体 | 功能 | 对象范围 | 预期结果 |
|---|---|---|---|
| 普通用户 A | 查看订单详情 | A 的订单 | 允许 |
| 普通用户 A | 查看订单详情 | B 的订单 | 拒绝 |
| 客服 | 查看订单详情 | 负责区域订单 | 允许 |
| 客服 | 查看订单详情 | 非负责区域订单 | 拒绝 |
这一步可以帮助开发和测试对齐预期,避免“管理员能看全部”这类过于粗糙的判断。
步骤三:检查异常状态码和返回体
权限拒绝时建议统一返回 403 或业务层无权限错误,不要返回真实对象信息。对于不存在和无权限是否区分,要结合业务场景决定。面向公网的敏感资源接口,可以考虑统一返回模糊结果,减少资源枚举信息泄露。
{
"code": "FORBIDDEN",
"message": "no permission to access this resource"
}步骤四:覆盖非主路径接口
越权常出现在“非主路径”上,例如:
- 附件预览、文件下载、缩略图接口。
- 批量导出、报表查询、异步任务结果下载。
- 移动端、小程序、旧版 H5 接口。
- GraphQL 或聚合查询接口。
- 后台调试接口、灰度接口。
这些接口往往不是业务主流程,却能拿到同样敏感的数据。
常见修复方式
1. 在服务端统一获取当前身份
不要信任客户端传来的 userId、role、tenantId。客户端可以传展示参数,但权限相关字段必须从服务端会话、token 解析结果或可信网关透传中获取,并且要校验签名和来源。
Long currentUserId = AuthContext.userId();
Long currentTenantId = AuthContext.tenantId();2. 权限条件下沉到查询层
对于普通用户资源访问,推荐使用带归属条件的查询:
Order order = orderRepository.findOne(
where("id", orderId)
.and("user_id", currentUserId)
.and("tenant_id", currentTenantId)
);这样即使上层调用者遗漏了一次判断,也不容易直接返回越权对象。
3. 管理端引入数据范围
管理端不是天然拥有全部数据。客服、运营、区域管理员通常都有数据范围限制。可以把数据范围抽象成策略:
DataScope scope = dataScopeService.resolve(currentAdmin);
query.and("tenant_id", scope.tenantId());
query.andIn("region_id", scope.allowedRegions());如果管理端使用 RBAC,只解决了“能不能打开功能”,还需要结合 ABAC 或数据范围模型解决“能看哪些数据”。
4. 文件资源单独授权
文件下载接口很容易被遗漏。不要只依赖文件名随机、URL 难猜。推荐文件表记录业务归属,下载时按 file_id 查询并校验 owner、tenant、业务状态。
SELECT f.id, f.storage_key
FROM files f
JOIN orders o ON f.biz_id = o.id
WHERE f.id = #{fileId}
AND f.biz_type = 'ORDER'
AND o.user_id = #{currentUserId}如果使用对象存储临时链接,也应该在生成临时链接前完成权限校验,并设置合理过期时间。
日志与监控建议
越权问题在修复后还需要观察是否存在历史异常访问。建议至少记录以下字段:
- 当前用户 ID、租户 ID、角色。
- 访问资源类型和资源 ID。
- 授权结果:允许或拒绝。
- 拒绝原因:未登录、角色不足、对象不归属、数据范围不足。
- 请求来源 IP、User-Agent、traceId。
可以针对短时间大量 403、连续访问不同资源 ID、跨租户访问尝试等行为做告警。注意日志中不要直接记录完整身份证号、手机号、token 等敏感信息。
代码审计检查清单
- 所有详情、修改、删除、下载接口是否做了对象级授权。
- 是否存在只校验登录态、不校验资源归属的接口。
- 客户端传入的 userId、tenantId 是否被后端直接信任。
- 列表接口和详情接口权限条件是否一致。
- 导出、批量、异步任务结果接口是否复用了相同权限规则。
- 管理端是否具备数据范围控制,而不是只做角色判断。
- 文件、图片、附件接口是否校验业务归属。
- 旧接口、移动端接口、灰度接口是否纳入权限测试。
- ORM 查询是否存在绕过全局租户过滤器的原生 SQL。
- 权限拒绝是否有统一错误处理和审计日志。
注意点
越权漏洞的修复不要只靠前端隐藏按钮,也不要只靠 ID 随机化。真正可靠的做法是服务端基于可信身份做对象级授权,并让权限条件进入查询链路。
- 不要把 JWT 中的 role、tenant 信息长期视为实时权限,权限变更后要考虑 token 失效或版本号机制。
- 多租户系统要优先校验 tenant 边界,避免跨租户数据泄露。
- 缓存层要包含权限维度,避免用户 A 的缓存结果被用户 B 命中。
- 批量接口要逐项校验,不要只校验第一个 ID。
- 异步任务创建和结果下载都要校验权限,不能只在创建时校验。
总结
API 越权不是复杂漏洞,但在真实系统里很容易反复出现。它通常不是某一行代码的问题,而是权限模型、接口设计、服务复用和数据查询习惯共同造成的。
我的经验是,治理这类问题要抓三点:第一,明确主体、功能、对象三层权限;第二,服务端统一从可信上下文取身份,不信任客户端权限参数;第三,把对象归属和数据范围条件下沉到查询层。只要这三点落实,大多数水平越权和跨租户访问问题都能明显减少。
推荐意见