APIセキュリティ対策:BOLA・認可漏れをDB・キャッシュ・非同期処理で防ぐ
ログイン済みでも、他社の請求書を読めてはいけない。認証と認可の違いから、BOLA・IDOR、入力項目の制限、テナント別キャッシュ、CSV出力時の再認可、安全なログまで、請求書SaaSの例で実装とテストを解説します。
APIセキュリティ対策で、認証の次に何を確認する?
ログインを必須にしたAPIでも、別の会社の請求書IDを渡すと内容が返るなら、情報漏えいは防げません。認証は呼び出し元の身元を確かめる処理、認可はその主体がその対象にその操作を行えるかを判定する処理です。正しいトークンを持つ利用者にも、拒否すべき操作があります。
OWASP API Security Top 10の2023年版は、オブジェクト単位の認可の不備をAPI1として扱っています。BOLA(Broken Object Level Authorization)は、呼び出せるAPIの中で、操作してよい対象の確認が抜ける問題です。IDを直接参照して他人のデータへ届くIDORも、この問題を考える代表的な入口です。UUIDで推測しにくくしても、共有URLなどでIDを知った利用者を止めるには認可が必要です。
最初にデータフローを描き、守る境界を決める
請求書の番号・金額・宛先に加え、CSV出力と認証情報も守る対象です。画面だけを見ていると、出力ワーカーやログ収集先を見落とします。脅威モデリングでは、データの流れと信頼の前提が変わる境界を描き、悪用経路・対策・確認方法を結び付けます。
利用者 → API(身元・所属・操作権限) → API → DB/キャッシュ(対象・テナント・返す項目) → API → キュー → ワーカー(保護された依頼情報・実行時の権限) → ワーカー → 非公開CSV → 取得API(成果物への現在の権限) → API/ワーカー → ログ(許可した診断項目のみ)
| 入口 | この例で許可する操作 | 拒否する反例 |
|---|---|---|
| 請求書の取得・一覧 | 所属会社の閲覧者が自社の請求書を読む | A社の利用者がB社のIDを指定する |
| 請求書のメモ更新 | 所属会社の経理担当者が自社のメモを更新 | 閲覧者の更新、tenant_idや金額の変更 |
| CSV出力・取得 | 現在も出力権限のある担当者が自社分を取得 | 開始前に権限を外された依頼、他社の成果物取得 |
「管理者だから許可」だけでは所属会社が抜け、「自社だから許可」だけでは閲覧者も更新できます。この表を実装とテストへ渡します。会社をまたぐ管理操作が必要なら、通常の取得とは別に、権限と監査を明示した経路を設けます。
BOLA・IDOR対策:テナントと操作権限をデータ取得に結び付ける
危険な実装は、ログイン確認後に請求書IDだけで検索するものです。取得の前に所属と操作権限を確認し、データ取得の条件にもテナントを含めます。更新・削除・一覧・一括取得にも同じ境界が必要です。
principal = authenticate(request)
tenant = requireCurrentMembership(principal, request.selectedTenant)
requirePermission(principal, tenant, "invoice:read")
invoice = invoices.forTenant(tenant).find(request.invoiceId)
if invoice is None:
return notFound()
return invoiceReadResponse(invoice)selectedTenantは所属会社を切り替えるための入力であり、そのまま信用せずサーバーで所属を検証します。この例では閲覧者は自社の全請求書を読めます。担当案件だけ読める製品なら、案件との関係も取得条件に加えます。ヘッダーやJWT内のtenant_idも、由来と必要な鮮度を検証せずに採用しません。
SELECT id, number, total, currency, memo
FROM invoices
WHERE tenant_id = $1 AND id = $2;共通のデータアクセス層でテナントを必須にし、書き忘れを減らします。認可を確認できない場合に、条件を外して検索するフォールバックは設けません。対象の存在を隠すなら取得不能を404にそろえるなど応答方針を決め、一覧の件数やエラー本文からも他社の情報が漏れないか確認します。
入力検証:tenant_idや権限を丸ごと保存しない
メモ更新に正しいJSONが届いても、本文へtenant_idやtotalを追加され、保存モデルへ丸ごとコピーすると、所属や請求額まで変わる可能性があります。この例で受け付ける項目はmemoだけです。返す情報も専用の応答型で選び、内部列や連携用の秘密情報を自動シリアライズしません。OWASPのAPI3は、こうしたプロパティ単位の認可不備を扱っています。
input = validateMemoRequest(request.body)
# Exactly { memo: string }; reject unknown fields; enforce length limit.
principal = authenticate(request)
tenant = requireCurrentMembership(principal, request.selectedTenant)
requirePermission(principal, tenant, "invoice:memo:update")
updated = invoices.forTenant(tenant).updateMemo(request.invoiceId, input.memo)
if not updated:
return notFound()
return noContent()型・必須・長さ・範囲をサーバーで検証し、受信サイズの上限は本文をすべて読み込む前にも設けます。JSONの形式が正しくても業務上の条件を満たすとは限りません。SQLの値のバインドと対象への認可も、それぞれ必要です。
キャッシュ対策:ヒット時にも認可し、共有範囲でキーを分ける
A社とB社に別の請求書42があるとします。DBでは会社を絞っていても、キーがinvoice:42なら、A社が温めた値をB社へ返す経路ができます。キャッシュヒットではDBのWHERE条件もRLSも実行されません。
tenant = requireCurrentMembership(principal, selectedTenant)
requirePermission(principal, tenant, "invoice:read")
key = encodeKey(["invoice-response-v1", tenant.id, invoiceId])
value = cache.get(key)
if value is None:
value = invoices.forTenant(tenant).find(invoiceId)
if value is None:
return notFound()
value = invoiceReadResponse(value)
cache.put(key, value, configuredTTL)
return valueキーは配列から衝突なく符号化する想定です。同じ会社でも利用者で見える項目が違うなら、その区別もキーと応答生成に反映します。認可の判定をキャッシュする場合は、古い許可が残る期限と失効方法を別に決めます。応答キャッシュのTTLを短くするだけでは、権限剥奪直後の拒否を保証できません。
非同期処理:CSV出力は受付・実行・取得で確認する
今回の要件は「実行開始前に権限を外したら、待機中のCSV出力も止める」です。受付時の確認だけでは、数分後に動くワーカーに古い許可が残ります。ワーカー自身のDB接続権限も、依頼者の出力権限を証明しません。
- 受付:依頼者・所属・出力権限・対象を検証し、改ざんを防ぐジョブ情報へ保存する。メッセージ内の未検証のtenant_idで上書きさせない。
- 実行:信頼したキュー経路から受け、現在の依頼者の権限と対象テナントを再確認する。確認不能なら実行せず保留・拒否し、再開方針を決める。
- 取得:CSVを非公開で保存し、取得時にも成果物への現在の権限を確認する。ジョブIDやファイル名を知っているだけでは渡さない。
長い出力では途中の確認点と、部分的なファイルの破棄も決めます。実行直前の確認だけで、その後の権限変更まで即座に反映できるわけではありません。いつまでに剥奪を反映するかを要件にします。受付で取引を確定する別の業務なら、後の剥奪で取り消すべきかも別途判断します。
ログ対策:拒否を追い、秘密情報は複製しない
要求全文を記録すると、Authorization、Cookie、署名付きURL、請求書本文が別の保存先へ流れます。許可した項目を構造化して残し、アクセスログ・例外収集・ワーカーにも同じ基準を適用します。
{"event":"invoice_read_denied","request_id":"r42","route":"/invoices/:id","status":404,"error_code":"resource_unavailable"}主体やテナントの識別子が必要なら、目的を決めて最小限の監査項目を追加し、閲覧権限と保持期間を管理します。自由入力のエラー文やヘッダーをそのまま入れず、正常・入力不正・外部障害の各経路でダミーの秘密値が残らないか確認します。要求IDは記録の相関付けに使い、認可の根拠にはしません。
実装後のチェックリスト:拒否すべき操作をテストする
成功する要求だけでは境界を検証できません。管理する検証環境にA社・B社のデータと、A社の閲覧者・経理担当者を用意します。HTTPステータスに加え、返却本文・DBの変更・成果物・ログまで確認します。
| 試す操作 | 期待する結果 | 確認するもの |
|---|---|---|
| A社からB社のIDで取得・更新・削除 | 本文を返さず、変更もしない | 個別・一括API、一覧・検索の結果と件数 |
| A社の閲覧者がメモ更新 | 操作を拒否する | DBのメモが変わっていないこと |
| 更新本文にtenant_id・totalを追加 | 今回の契約では入力を拒否する | 所属と請求額が変わっていないこと |
| A社で42をキャッシュしてB社で42を取得 | B社の値だけを返す | 逆順、ミス、権限剥奪後のヒット |
| 出力受付後、実行開始前に権限を剥奪 | 今回の要件では出力を実行しない | ジョブ結果とCSVが作られていないこと |
| 他社の成果物取得・自社の取得権限剥奪 | 取得を拒否する | 直接URLを含む全配信経路 |
| 不正入力と例外にダミーの秘密値を混ぜる | 秘密値をログへ残さない | API、プロキシ、ワーカー、例外収集先 |
許可される操作も試し、すべて拒否しているだけの実装を見逃さないようにします。検索APIや出力方式を増やすときは、境界の表と回帰テストを更新します。テスト成功が、未検証の管理経路や本番設定まで安全と証明するわけではありません。
よくある疑問:APIキー・UUID・WAFがあれば十分?
- APIキーを必須にすればよい? キーが示す主体と権限の範囲を確認し、対象への操作を認可します。所持だけで全社の請求書を読めることにはしません。
- UUIDに変えればIDORは防げる? 入手したIDへのアクセス権は別に確認します。画面でリンクを隠すだけでもAPIの直接呼び出しは止まりません。
- WAFや入力検証で認可漏れを防げる? 正しい形式のIDを持つ利用者にも拒否すべき操作があります。所属・対象・操作を判定する認可が必要です。
実装判断とテストに使う一次資料
- OWASP API Security Top 10 2023 — API1: Broken Object Level Authorization
- OWASP — Threat Modeling Cheat Sheet
- OWASP — Authorization Cheat Sheet
- OWASP — Input Validation Cheat Sheet
- OWASP API Security Top 10 2023 — API3: Broken Object Property Level Authorization
- PostgreSQL 17 — Row Security Policies
- OWASP — Multi-Tenant Security: cache, asynchronous work, and storage
- OWASP — Logging Cheat Sheet
- OWASP Web Security Testing Guide — API Broken Object Level Authorization