Skip to content
Published on

认证、授权,以及 IDOR — 登录明明是正常的,却能看到别人的数据

分享
Authors

开篇 — 登录是正常的,却打开了别人的订单

客户来了个咨询。说是刷新订单详情页之后,冒出来一张写着别的公司名字的订单。于是去看日志。

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 的订单 ID404200 加响应体
水平提升,列表列表 API 查询时没带租户条件用 B 的令牌请求全量列表只有 B 的条目全部条目
垂直提升,功能普通用户访问管理员专用端点用普通令牌调用删除用户 API403200
垂直提升,字段通过批量赋值修改角色字段请求体里带上 role 字段去改自己的资料400 或被忽略角色被改掉
状态迁移只有归属者才能做的状态变更用参与者的令牌调用支付审批403状态被改变
间接引用只检查子资源,不检查父资源我的订单 ID 加上别人的附件 ID 组合404附件被返回
暴露存在性无权限和不存在返回不同的状态码分别请求存在的 ID 和不存在的 ID两个都 404403 与 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。前者是显示逻辑,后者是在调节发现难度。两样都有用,但它们哪一个都没有回答"这个人能不能访问这份资源"这个问题。回答这个问题的代码,必须待在查询条件里面。