- 开篇 — 登录是正常的,却打开了别人的订单
- 认证和授权是两个不同的问题
- IDOR 的结构 — 以及 UUID 并不是解决方案
- 授权检查该放在哪里
- 多租户 — 让数据库来强制租户条件
- 在前端把按钮藏起来并不是授权
- 双账号交叉测试与审计日志
- 收尾 — 请把归属者放进查询条件里
开篇 — 登录是正常的,却打开了别人的订单
客户来了个咨询。说是刷新订单详情页之后,冒出来一张写着别的公司名字的订单。于是去看日志。
04:11:52 GET /v1/orders/10421 200 user=usr_8f21 tenant=tnt_a91 12ms
04:11:58 GET /v1/orders/10422 200 user=usr_8f21 tenant=tnt_a91 9ms
04:12:03 GET /v1/orders/10423 200 user=usr_8f21 tenant=tnt_a91 11ms
全都是 200。认证中间件工作正常,令牌有效,会话也没过期。一个失败的请求都没有,所以告警也没响。
问题在于 10422 号和 10423 号订单并不属于 tnt_a91。代码是这样写的。
app.get('/v1/orders/:id', requireAuth, async (req, res) => {
const order = await db.order.findUnique({ where: { id: Number(req.params.id) } })
if (!order) return res.status(404).end()
res.json(order)
})
requireAuth 确认的是"这个请求来自谁"。然后就没有然后了。问一句"这个人能不能看这张订单"的代码,一处都没有。
这就是 IDOR,是实务中发生最频繁、发现最晚的一类问题。因为请求是成功的,响应码是 200,也不会抛出任何异常。本文要讲的是这个缺陷为什么会反复出现,以及从结构上该怎么挡住它。
认证和授权是两个不同的问题
这两个词经常一起出现,但它们回答的问题不同。认证问的是"你是谁",授权问的是"你能不能对这个对象做这个动作"。
技术性质上也不一样。认证每个请求只做一次,在应用边界上一处完成。验证令牌、确定主体,就结束了。所以一行中间件就能解决,漏掉了也立刻会被看见。少了认证的端点,只要不登录去调一下就当场露馅。
授权正相反。它要在每个请求里做很多次,而且要针对每个目标对象分别判断。哪怕只是展示一张订单的请求,也牵涉到订单的归属、附件的归属、关联客户信息的访问权限,各是一份。判断所需的信息在数据库里,所以没法在一处中间件里收尾。于是它就散开了,一散开就会漏。
认证缺陷和授权缺陷的发现难度也不一样。没有认证,匿名请求就会成功,任何扫描器都抓得到。没有授权,它看起来就像正常用户的正常请求,自动扫描器抓不到。只有准备两个账号、两份资源,交叉着调用一遍,它才会现形。
把权限提升的路径按类型整理一遍,要测什么就清楚了。
| 类型 | 场景 | 测试请求 | 安全的响应 | 有漏洞时的响应 |
|---|---|---|---|---|
| 水平提升,对象 | 读取同级别的其他租户资源 | 用 B 的令牌去读 A 的订单 ID | 404 | 200 加响应体 |
| 水平提升,列表 | 列表 API 查询时没带租户条件 | 用 B 的令牌请求全量列表 | 只有 B 的条目 | 全部条目 |
| 垂直提升,功能 | 普通用户访问管理员专用端点 | 用普通令牌调用删除用户 API | 403 | 200 |
| 垂直提升,字段 | 通过批量赋值修改角色字段 | 请求体里带上 role 字段去改自己的资料 | 400 或被忽略 | 角色被改掉 |
| 状态迁移 | 只有归属者才能做的状态变更 | 用参与者的令牌调用支付审批 | 403 | 状态被改变 |
| 间接引用 | 只检查子资源,不检查父资源 | 我的订单 ID 加上别人的附件 ID 组合 | 404 | 附件被返回 |
| 暴露存在性 | 无权限和不存在返回不同的状态码 | 分别请求存在的 ID 和不存在的 ID | 两个都 404 | 403 与 404 有区分 |
最后一行常被忽略。无权限时返回 403、资源不存在时返回 404,那么单凭响应码就能推断哪些 ID 是真实存在的。对于没有查看权限的资源,原则是连它的存在本身都要藏起来,所以统一成 404 更好。
IDOR 的结构 — 以及 UUID 并不是解决方案
IDOR 的形态很简单。把请求里带来的标识符直接当作查询条件用,却不确认结果是不是请求者自己的。
// 脆弱:只凭 ID 查询
const order = await db.order.findUnique({ where: { id: orderId } })
// 安全:把归属关系放进查询条件里
const order = await db.order.findFirst({
where: { id: orderId, tenantId: req.user.tenantId },
})
if (!order) return res.status(404).end()
差别在于检查是放在之后做,还是包含进查询里。查完再比较的写法也能跑,但留给失误的空间很大。
// 能跑,但很容易变得脆弱的写法
const order = await db.order.findUnique({ where: { id: orderId } })
if (order.tenantId !== req.user.tenantId) return res.status(404).end()
这段代码只要有人删掉那一行检查、把条件写反,或者复制到新端点时漏掉,立刻就变脆弱。而把条件包含进查询的写法,一旦漏掉结果就是空的、功能跑不起来,于是在开发阶段就暴露出来了。朝安全一侧失败的设计更好。
这里必须更正一条流传最广的错误建议:所谓"把顺序 ID 换成 UUID,IDOR 就解决了"。
顺序 ID 确实会把问题放大。看到 10421 就能去试 10422,一行循环就能把整段扫一遍。
for i in $(seq 10400 10500); do
code=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: Bearer $TOKEN" "https://api.example.com/v1/orders/$i")
[ "$code" = "200" ] && echo "leaked: $i"
done
换成 UUID 之后这个循环就跑不动了。所以它看起来很有效。但 UUID 改变的是发现难度,而不是权限检查的有无。而且标识符泄漏出去,比你想象的容易得多。
只要列表 API 里混进了别的租户的条目,ID 就原样暴露了。分享链接、邮件通知、webhook 负载、导出文件、工单附件、浏览器历史、referrer 头、服务器访问日志,还有前端打包产物里残留的示例值,都在源源不断地往外漏。如果是协作类服务,那些被邀请过又被移出去的用户,早就知道 ID 了。
UUID 本身的性质也要看。带有时间和节点信息的版本 1 UUID 实质上是可预测的;最近常用的时间可排序标识符,前面几位就是时间戳,探索空间因此大幅缩小。如果需要随机性,就该用版本 4。
整理下来就是这样。UUID 是让枚举变难的缓解措施,而授权检查是无可替代的管控。两件事都做才对,但顺序不能颠倒。有授权检查,顺序 ID 也是安全的;没有授权检查,UUID 也只是时间问题。
授权检查该放在哪里
在每个控制器里都塞一遍检查,这种做法必然失败。原因不是人不小心,而是算术。端点有 200 个,每个平均处理两个对象,检查点就是 400 个。每加一个新功能它就变多,评审者每一次都得记着这件事。400 个里漏掉一个就是事故。
解决方向有两个。把检查往下压到数据访问层,或者把它往上提到策略引擎。
往下压到数据访问层,意思是"让没有作用域的查询根本不可能写出来"。
from dataclasses import dataclass
from sqlalchemy.orm import Session
@dataclass(frozen=True)
class Principal:
user_id: str
tenant_id: str
roles: frozenset[str]
class ScopedRepository:
"""不经过这个类就访问不到数据。"""
def __init__(self, session: Session, principal: Principal):
self._session = session
self._principal = principal
def orders(self):
return self._session.query(Order).filter(Order.tenant_id == self._principal.tenant_id)
def order(self, order_id: str) -> Order | None:
return self.orders().filter(Order.id == order_id).one_or_none()
def invoices(self):
return self._session.query(Invoice).filter(Invoice.tenant_id == self._principal.tenant_id)
控制器就缩成这样。
@router.get("/v1/orders/{order_id}")
def get_order(order_id: str, repo: ScopedRepository = Depends(scoped_repo)):
order = repo.order(order_id)
if order is None:
raise HTTPException(status_code=404)
return OrderOut.model_validate(order)
这里关键的是强制执行这个模式的装置。只要有人直接写 session.query(Order),作用域就没了,所以需要一条禁止它的规则。
# .semgrep/no-unscoped-query.yaml
rules:
- id: unscoped-orm-query
languages: [python]
severity: ERROR
message: 不要直接从 session 查询,请使用 ScopedRepository。
patterns:
- pattern-either:
- pattern: $SESSION.query(...)
- pattern: $MODEL.objects.filter(...)
- pattern-not-inside: |
class ScopedRepository:
...
paths:
exclude:
- tests/
- migrations/
semgrep --config .semgrep/no-unscoped-query.yaml src/
src/api/reports.py
57┆ rows = session.query(Order).filter(Order.status == "paid").all()
⚠ unscoped-orm-query
不要直接从 session 查询,请使用 ScopedRepository。
1 finding.
往上提到策略引擎,意思是把判断逻辑挪到代码之外。规则复杂,或者多个服务要共享同一套规则时,这条路更划算。
package authz
import rego.v1
default allow := false
# 同一租户的资源可以读取
allow if {
input.action == "orders:read"
input.resource.tenant_id == input.subject.tenant_id
}
# 退款必须是同一租户,并且具备管理员角色
allow if {
input.action == "orders:refund"
input.resource.tenant_id == input.subject.tenant_id
"billing_admin" in input.subject.roles
}
把策略分离出来,测试也就跟着分离了。
package authz_test
import rego.v1
import data.authz
test_cross_tenant_read_denied if {
not authz.allow with input as {
"action": "orders:read",
"subject": {"tenant_id": "tnt_b04", "roles": []},
"resource": {"tenant_id": "tnt_a91"},
}
}
无论选哪一条路,原则都一样。授权判断发生的位置,必须在代码库里数得清。位置有 400 处就管不住,只有两三处才评审得动、测试得了。
多租户 — 让数据库来强制租户条件
无论应用层的管控做得多好,批处理作业、管理员工具、数据迁移脚本、报表查询都会绕过它。把最后一道防线放在数据库上,这些路径也就被覆盖住了。
PostgreSQL 的行级安全就是这件工具。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id', true)::uuid)
WITH CHECK (tenant_id = current_setting('app.tenant_id', true)::uuid);
这里有三个容易被漏掉的配置。
没有 FORCE ROW LEVEL SECURITY,表的属主就会绕过策略。如果迁移用的账号和应用用的账号是同一个,那策略实际上就等于关掉了。超级用户和带 BYPASSRLS 属性的角色也永远绕过。必须确认应用账号没有这个属性。
没有 WITH CHECK,就只挡住了读取,用别的租户 ID 插入行仍然是被允许的。
把 current_setting 的第二个参数设为 true,在设置缺失时返回的就是 NULL 而不是报错。和 NULL 做比较结果为假,所以一行都看不到。也就是说,忘了设置的时候,它是朝"什么都看不到"而不是"全都看得到"的方向失败的。
上下文要设置在事务范围内。在用连接池的环境里若设成会话范围,值就会漏到下一个请求里去。
BEGIN;
SET LOCAL app.tenant_id = '3f2b8c14-9a71-4f0e-8d33-6c2a15b7e901';
SELECT id, total_amount FROM orders WHERE id = 10422;
COMMIT;
id | total_amount
----+--------------
(0 rows)
在应用代码里自动挂上去。
from sqlalchemy import event, text
from sqlalchemy.orm import Session
@event.listens_for(Session, "after_begin")
def apply_tenant_context(session, transaction, connection):
tenant_id = current_tenant.get(None)
if tenant_id is None:
raise RuntimeError("没有租户上下文就不能开启事务")
connection.execute(
text("SELECT set_config('app.tenant_id', :tenant_id, true)"),
{"tenant_id": str(tenant_id)},
)
请务必留一个测试来确认策略真的在生效。RLS 很容易悄无声息地被关掉。
-- 找出没有挂上策略的表
SELECT c.relname AS table_name, c.relrowsecurity, c.relforcerowsecurity
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'app' AND c.relkind = 'r'
ORDER BY c.relrowsecurity, c.relname;
table_name | relrowsecurity | relforcerowsecurity
------------------+----------------+---------------------
audit_events | f | f
webhook_deliver | f | f
orders | t | t
invoices | t | t
为了防止新表在没有策略的情况下被部署上去,最好把这条查询做成 CI 检查。
在前端把按钮藏起来并不是授权
评审时如果得到的回答是"普通用户看不到这个按钮",那不是对授权的说明。那是 UI 的显示逻辑。
// 只是画面控制,并不能保证服务器会拒绝请求
{user.role === 'admin' && <DeleteButton onClick={remove} />}
服务器到底是怎么行为的,只有亲自把请求发出去才知道。
curl -sS -X POST https://api.example.com/v1/users/812/role \
-H "Authorization: Bearer $VIEWER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"role":"admin"}' -w '\n%{http_code}\n'
{"id":812,"email":"viewer@example.com","role":"admin"}
200
按钮藏起来了,API 却是敞开的。一个只读权限的账号,把自己变成了管理员。
同一个问题的变体是批量赋值。如果资料修改 API 把请求体原样映射到模型上,那么 UI 上没有的字段也会被改掉。
// 脆弱:整个请求体都会被写入
await db.user.update({ where: { id: req.user.id }, data: req.body })
curl -sS -X PATCH https://api.example.com/v1/me \
-H "Authorization: Bearer $VIEWER_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"displayName":"Kim","role":"admin","tenantId":"tnt_a91"}'
防御的办法是把允许的字段明确写出来。必须是白名单而不是黑名单。因为新字段一加进来,黑名单就自动被绕过了。
import { z } from 'zod'
const UpdateMe = z
.object({
displayName: z.string().min(1).max(80),
locale: z.enum(['ko', 'en', 'ja']).optional(),
})
.strict()
app.patch('/v1/me', requireAuth, async (req, res) => {
const parsed = UpdateMe.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'invalid payload' })
const user = await db.user.update({ where: { id: req.user.id }, data: parsed.data })
res.json({ id: user.id, displayName: user.displayName })
})
GraphQL 和 gRPC 也不例外。反而因为字段级访问变多了,检查点也随之增多。与其在每个解析器里都放一遍授权,不如在数据加载器那一层强制作用域来得安全。
双账号交叉测试与审计日志
授权缺陷看起来就像正常请求,所以要自动抓到它就需要专门的测试。原理很简单。用账号 A 建出资源,再用账号 B 的令牌对同一份资源尝试所有动作。
import pytest
# 由 fixture 填入账号 A 创建的资源标识符
CROSS_TENANT_CASES = [
("GET", "/v1/orders/{order_id}"),
("PATCH", "/v1/orders/{order_id}"),
("DELETE", "/v1/orders/{order_id}"),
("GET", "/v1/orders/{order_id}/attachments/{attachment_id}"),
("GET", "/v1/invoices/{invoice_id}"),
("POST", "/v1/invoices/{invoice_id}/refund"),
("GET", "/v1/webhooks/{webhook_id}"),
]
@pytest.mark.parametrize("method,template", CROSS_TENANT_CASES)
def test_tenant_b_cannot_touch_tenant_a(client, tenant_a_resources, tenant_b_auth, method, template):
url = template.format(**tenant_a_resources)
response = client.request(method, url, headers=tenant_b_auth, json={})
assert response.status_code == 404, (
f"{method} {url} 返回了 {response.status_code}。"
f"响应体片段: {response.text[:120]}"
)
FAILED tests/test_authz.py::test_tenant_b_cannot_touch_tenant_a[GET-/v1/orders/{order_id}/attachments/{attachment_id}]
AssertionError: GET /v1/orders/ord_5512/attachments/att_9931 返回了 200。
响应体片段: {"id":"att_9931","filename":"2026-06-invoice.pdf","url":"https://cdn...
订单本身挡住了,附件却被打穿了。这是只检查父资源、不检查子资源的典型形态。靠人做评审很难抓到,靠这个测试则当场就能抓到。
再往前走一步,可以把注册的路由列表和测试列表对一遍。新端点加进来了却没有交叉测试,就让它失败。
def test_every_resource_route_has_cross_tenant_case(app):
covered = {template for _, template in CROSS_TENANT_CASES}
exempt = {"/health", "/v1/auth/login", "/v1/auth/refresh", "/v1/signup"}
missing = [
route.path
for route in app.routes
if "{" in route.path and route.path not in covered and route.path not in exempt
]
assert not missing, f"缺少交叉租户测试的路由: {missing}"
仅这一个测试,就能挡住"新做的端点忘了加授权"这一类事故的绝大部分。要点在于把检查交给 CI,而不是交给人的记忆。
最后一项是审计日志。这里常犯的错误是只记录成功。
{
"ts": "2026-07-26T04:12:03.117Z",
"request_id": "01J8ZQ4T7K3M9F0X2B6C5A1D8E",
"actor_id": "usr_8f21",
"actor_tenant": "tnt_a91",
"action": "orders:read",
"resource_type": "order",
"resource_id": "ord_10423",
"resource_tenant": "tnt_b04",
"decision": "deny",
"reason": "tenant_mismatch",
"source_ip": "203.0.113.44",
"user_agent": "python-requests/2.32.3"
}
这条记录里必须具备的字段有四个:行为者的租户、目标资源的租户、判定结果,以及判定理由。只有前两个同时在场,才能用查询把交叉访问的尝试找出来。
SELECT actor_id, count(*) AS denies, count(DISTINCT resource_id) AS distinct_targets
FROM audit_events
WHERE decision = 'deny'
AND reason = 'tenant_mismatch'
AND ts > now() - interval '10 minutes'
GROUP BY actor_id
HAVING count(*) > 20
ORDER BY denies DESC;
actor_id | denies | distinct_targets
----------+--------+------------------
usr_8f21 | 317 | 317
10 分钟之内在 317 个互不相同的资源上被拒绝。这意味着枚举尝试正在进行,而只要不记录拒绝,这个信号根本就不存在。反过来,如果授权被打穿了,所有请求都会成功,拒绝日志就会归零。所以这个指标在两个方向上都有用。突然变多说明有人在攻击,发版之后突然消失则说明检查没了。
收尾 — 请把归属者放进查询条件里
这篇文章的结论可以缩成一句话。不要在把资源取回来之后再确认权限,而要把权限放进查询条件里。
仅这一个习惯,就能挡住 IDOR 的绝大部分。放进条件里,一旦漏掉结果就是空的、功能跑不起来,所以在开发阶段就暴露了。留到之后再确认,漏掉了也什么都不会发生,所以它会在生产环境里以事故的形式暴露。
其余的都是为了维持这个习惯而准备的装置。让无作用域查询无法写出来的数据访问层,应用出错时还能兜底的行级安全,防止新端点不带检查就被加进来的交叉租户测试,以及让枚举尝试变得可见的拒绝日志。
另外要记住有两样东西不是授权:在前端把按钮藏起来,以及把标识符换成 UUID。前者是显示逻辑,后者是在调节发现难度。两样都有用,但它们哪一个都没有回答"这个人能不能访问这份资源"这个问题。回答这个问题的代码,必须待在查询条件里面。
현재 단락 (1/267)
客户来了个咨询。说是刷新订单详情页之后,冒出来一张写着别的公司名字的订单。于是去看日志。