KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

05 · 隔离与泄密面:越权、403 与 404 的取舍 — keel 龙骨

前四章把正常的链路搭起来了。这一章专门讲哪里会漏:五类越权尝试、状态码本身泄露的信息、以及那些"看起来隔离了其实没有"的写法。判断标准很简单——任何一条路径,如果它的作者忘了加租户条件,隔离就失效了,那它就是漏的。

前四章把正常的链路搭起来了。这一章专门讲哪里会漏:五类越权尝试、状态码本身泄露的信息、以及那些"看起来隔离了其实没有"的写法。判断标准很简单——任何一条路径,如果它的作者忘了加租户条件,隔离就失效了,那它就是漏的。


一、现场:用状态码探测别人的资源

一个多租户系统,攻击者(或好奇的用户)做了一件事:依次请求 /api/agents/agent_1001、agent_1002……

发现规律:

agent_1001 → 404
agent_1002 → 403   ← 这个 id 存在,只是我没权限
agent_1003 → 404

403 与 404 的差异,直接把"哪些资源存在"泄露了出去。对于 B 端 SaaS,这意味着竞争对手能扫出你的客户名单、Agent 数量与命名规律。

这不是理论问题:状态码是这个系统自己提供的 oracle。

二、概念边界:五类越权与它们各自的入口

类型 含义 典型入口 配套项目用例
未认证越权 没有凭证就访问 依赖未挂 / 挂错 test_missing_credentials
跨租户越权 属于 A 租户,访问 B 租户的数据 漏 tenant 条件 test_resource_lookup_is_scoped_by_tenant
角色越权(垂直) 低权限角色调高权限接口 接口层没校验 test_menu_visible_but_api_forbidden
资源越权(水平) 同租户内访问别人的数据 缺资源级 ACL test_acl_insufficient_level
数据范围越权 能调列表,但范围没生效 查询没注入条件 test_data_scope_differs_by_role

注意这五类不是并列的,它们分布在不同的层:第 1 类在认证层,第 2 类在隔离层,
第 3 类在功能权限层,第 4、5 类在数据层。排查"疑似越权"时,先判断是哪一类,
因为它们的现象相似但根因完全不同(都是"看到了不该看的")。

三、一次完整运行:统一成 404 的效果

配套项目里 acl.load_resource 的实现是关键:

def load_resource(conn, resource_id, tenant_id):
    # 两个条件缺一不可:只按 id 查,就是把租户隔离漏在业务代码里
    return one(conn,
        "SELECT ... FROM resource WHERE id = ? AND tenant_id = ?",
        (resource_id, tenant_id))

跨租户 id 与真不存在的 id 走同一条分支,都返回 ResourceNotFound(404)。实测(api_probe.py):

  读不存在的资源   GET /resources/r_nope     -> 404  resource_not_found
  跨租户读资源    GET /resources/r_flow_y    -> 404  resource_not_found   ← r_flow_y 属于 acme,身份在 globex

两行都是 404,外部观察者无法区分"这个 id 不存在"和"这个 id 属于别的租户"。

对比一下如果不这么做会怎样:

  读不存在的资源   -> 404 resource_not_found
  跨租户读资源    -> 403 forbidden_in_other_tenant    ← 泄露了"这个 id 是真实存在的"

404 不是万能的,要看攻击者能拿到什么

统一成 404 有代价,也有限度。判断标准是:攻击者手里有没有一份"存在性清单"。

场景 统一 404 的收益 风险来源
资源 id 是自增/可枚举的 高——阻止批量扫描 id 空间小,扫一遍成本低
资源 id 是 UUID 中——猜不到 仍然能通过"创建/删除"的反馈推断
资源名对外可见(如 URL slug) 低——本来就看得到 泄露渠道不是状态码
攻击者是该租户内部成员 中——阻止跨组织探测 内部人本来就知道一部分清单

结论:统一 404 是必要的一层,但它不能替代真正的隔离(每次查询都带 tenant 条件)。
它只是让"批量扫描探测"这条低成本攻击路径失效。

四、失败注入:五类越权逐个跑一遍

配套项目 test_auth.py 里每条测试都是一次主动破坏。这里按类型重新组织:

=== 认证层 ===
  无凭证                 -> 401 missing_user
  非该租户成员            -> 403 not_a_member_of_tenant

=== 隔离层 ===
  跨租户读资源            -> 404 resource_not_found     (与"不存在"同码)

=== 功能权限层 ===
  运营角色调删除接口       -> 403 missing_permission:system:user:delete
  运营角色调列表接口       -> 200 visible_users=1        (有权限,但数据受限)

=== 资源层 ===
  carol use 级读他人资源  -> 200 actual_level=use
  carol 读需要 observe    -> 403 access_level_denied:observe<use

=== 数据层 ===
  alice(all)=3 / bob(self)=1 / carol(org)=1

读这张表的方法:从上到下是"请求被挡在哪一层"。排障时先定位层,再定位具体判定,
比在业务代码里到处找要快得多——这也是 00 章那张六道闸图的实用价值。

一个真实会漏的地方:列表接口的"跨租户 id 拼接"

比资源详情更危险的是列表接口。常见的错法是:

# 错误:租户条件只在详情里写了,列表忘了
@app.get("/agents")
async def list_agents(identity = Depends(current_identity)):
    rows = conn.execute("SELECT * FROM agent WHERE org_id = ?", (identity.org_id,))  # 缺 tenant_id

如果 org_id 被不同租户复用(或者攻击者能拿到其他租户的 org_id),就会串数据。
判断标准:org_id / workspace_id 这类次级 id 在系统里是全局唯一还是租户内唯一?
如果只是租户内唯一(配套项目里就是这样),那所有查询都必须同时带 tenant_id 与 org_id。

配套项目通过在 member 里固化 org_id 来降低这个风险:
业务表存 member_id,org_id 只能从身份推导出来,不接受客户端传入。

五、误判澄清

误解 核对 结论
"统一 404 就安全了" 内部成员仍可能拿到 id 清单 404 只是阻断批量扫描,真正的隔离是每条查询都带 tenant 条件
"403 更友好,便于调试" 调试友好 = 生产泄露 生产统一 404,调试用日志与 trace id
"内部系统不用管越权" 内部越权导致的数据事故更常见 内部系统的越权多半是"顺手看到了",同样要处理
"越权是前端问题" 全部五个用例都在后端复现 前端只影响体验,后端才是边界
"只读接口不用鉴权" 读接口正是数据泄露的主路径 读接口的鉴权比写接口更容易被省略

六、一次真实请求的完整判定路径

把这一课的判定链画成时序(配套项目 api.py 的真实链路):

sequenceDiagram
    participant C as 客户端
    participant D as 依赖 current_identity()
    participant H as 业务端点
    participant ACL as acl.can_access

    C->>D: GET /resources/r_agent_x?level=observe<br/>X-User-Id: u_carol, X-Tenant-Id: t_acme
    D->>D: resolve_identity(user, tenant)
    alt 不是该租户成员
        D-->>C: 403 not_a_member_of_tenant
    else 身份有效
        D->>D: set_identity()(ContextVar,async 函数内)
        D-->>H: Identity(tenant_id=t_acme, member_id=m_carol_acme)
        H->>ACL: can_access("r_agent_x", "observe")
        ACL->>ACL: load_resource(id, tenant_id=服务端认定的)
        alt 资源不存在或不属于本租户
            ACL-->>C: 404 resource_not_found
        else 级别不足(carol 只有 use)
            ACL-->>C: 403 access_level_denied:observe<use
        else 通过
            ACL-->>C: 200 actual_level=...
        end
    end

图上有一条不能省略的线:ACL 用的是服务端认定的 tenant_id,
而不是客户端 header 里的那个。04 章说"返回服务端认定的值",这里就是它发挥作用的地方。

七、生产环境怎样替换

教学替身 生产替换 要点
统一 404 的两处分支 统一异常处理器 所有拒绝走同一出口,避免某个接口"忘了改"
明文 header 身份 签名 token 生产不可行
无审计 拒绝事件审计(谁、什么时候、试了哪个 id) 越权尝试是入侵信号,06/07 章会再提
无速率限制 登录/枚举接口限流 统一 404 之后,攻击成本转移到"扫 id",限流是最直接的缓解

八、练习与验收

练习 1:把你的系统里所有资源 id 的来源与可枚举性列出来(自增/UUID/可从列表拿到),
标出哪些地方存在"批量探测"风险。

练习 2:在你的系统里搜一遍 403 的返回点,判断哪些应该改成统一的 404,
并检查是否有接口把"资源存在但无权限"和"资源不存在"区分成了不同状态。

练习 3:为配套项目补两条测试:① 列表接口缺 tenant_id 条件时的失败;② 越权尝试的审计日志记录。

验收点:不看资料,说出五类越权分别落在哪一层;解释为什么跨租户与不存在要统一成 404 以及它的限度;说明"次级 id(org/workspace)是否全局唯一"如何影响查询写法。


现在能解释什么:五类越权与它们的层次分布;403/404 的取舍与统一 404 的限度;如何用"层"来定位越权问题;为什么只读接口更需要鉴权。

下一步:第 06 章讲 RBAC 的边界——什么时候角色模型撑不住,以及带权重的 ACL 怎么落地。

进入 keel 阅读