ブログ一覧

APIセキュリティ対策:BOLA・認可漏れをDB・キャッシュ・非同期処理で防ぐ

· 約10分で読めます

ログイン済みでも、他社の請求書を読めてはいけない。認証と認可の違いから、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出力と認証情報も守る対象です。画面だけを見ていると、出力ワーカーやログ収集先を見落とします。脅威モデリングでは、データの流れと信頼の前提が変わる境界を描き、悪用経路・対策・確認方法を結び付けます。

架空の請求書SaaSの主要経路。括弧内は各境界で確認すること

利用者 → 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も、由来と必要な鮮度を検証せずに採用しません。

PostgreSQL 17:上の取得処理が発行するSQL例。$1・$2はバインド値
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接続権限も、依頼者の出力権限を証明しません。

  1. 受付:依頼者・所属・出力権限・対象を検証し、改ざんを防ぐジョブ情報へ保存する。メッセージ内の未検証のtenant_idで上書きさせない。
  2. 実行:信頼したキュー経路から受け、現在の依頼者の権限と対象テナントを再確認する。確認不能なら実行せず保留・拒否し、再開方針を決める。
  3. 取得: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があれば十分?

実装判断とテストに使う一次資料

この記事で深掘りしたTips