- はじめに — ログインは正常なのに他人の注文書が開きます
- 認証と認可は違う問題です
- 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 のペイロード、エクスポートファイル、サポートチケットの添付、ブラウザ履歴、リファラヘッダ、サーバーのアクセスログ、フロントエンドのバンドルに残った例示値から流れ続けます。コラボレーションサービスなら、招待されたあとに外されたユーザーがすでに 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: セッションから直接参照せず 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
セッションから直接参照せず 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
# 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 個の異なるリソースで拒否されました。列挙の試みが進行中だという意味であり、この信号は拒否を記録しなければ存在しません。逆に認可が破られていればすべて成功するので拒否ログは 0 になります。ですからこの指標は二方向で有用です。急に増えれば攻撃の試みであり、デプロイ後に急に消えたなら検査が消えたということです。
おわりに — 参照条件に所有者を入れてください
この記事の結論は一行に縮まります。リソースを取ってきたあとに権限を確認するのではなく、権限を参照条件に入れてください。
このたった一つの習慣が IDOR のほぼすべてを防ぎます。条件に入れておけば、落としたときに結果が空になって機能が動かず、だから開発段階で露見します。あとで確認する方式は落としても何も起きず、だから運用で事故として露見します。
残りはこの習慣を維持するための仕掛けです。スコープのない参照を不可能にするデータアクセス層、アプリケーションがミスしても止めてくれる行レベルセキュリティ、新しいエンドポイントが検査なしで追加されるのを防ぐ交差テナントテスト、そして列挙の試みを見えるようにする拒否ログです。
そして二つは認可ではないという点を覚えておく必要があります。フロントエンドでボタンを隠すことと、識別子を UUID に変えることです。前者は表示ロジックで、後者は発見の難易度の調整です。どちらも有用ですが、どちらも「この人がこのリソースにアクセスしてよいか」という問いには答えません。その問いに答えるコードは参照条件の中になければなりません。
현재 단락 (1/267)
顧客からの問い合わせが入ります。注文詳細ページを再読み込みしたら別の会社名が書かれた注文書が出てきた、という内容です。ログを見ます。