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 怎么落地。