Claude Media
Claude CodeでIDOR(認可漏れ)をエンドポイント×所有者の表で洗い出す

Claude CodeでIDOR(認可漏れ)をエンドポイント×所有者の表で洗い出す

読み取り専用のサブエージェントでルートを全件列挙し、IDを受け取るハンドラごとに所有者検証の有無を表にして人が確かめる手順。検出を裏づける再現テストの書き方つき。

IDOR(Insecure Direct Object Reference、安全でないオブジェクト直接参照)は、リソースのIDを差し替えるだけで他人のデータに触れてしまう認可漏れです。OWASP Top 10:2021のA01 Broken Access Controlは、この型を「固有の識別子を渡すことで、他人のアカウントの閲覧や編集を許してしまう」ものとして挙げています。

見つけにくいのは、コードが壊れていないからです。ログインは通り、正常系のテストも通ります。抜けているのは「このレコードは、このユーザーのものか」を確かめる1行だけです。

そこで、全ルートを一覧にして、ハンドラごとに所有者チェックの有無を書き出す作業に落とします。列挙と判定はClaude Codeの読み取り専用サブエージェントに任せ、表を見て判断するのは人。最後に、疑わしい行を再現テストで裏づけます。

全体の流れ

手順

洗い出しの 5 段階

  1. 1

    ルート定義を全件列挙する

    フレームワークのルート登録箇所をGrepで拾い、メソッドとパスの一覧にします。

  2. 2

    ハンドラごとに 2 点を読む

    リソースIDを受け取るか。受け取るなら、所有者かテナントを検証しているか。

  3. 3

    根拠つきの表にする

    判定の根拠として、ファイル名と行番号を必ず添えさせます。

  4. 4

    人が表を見て疑わしい行を選ぶ

    「検証なし」と「確認できず」の行を人が読み直します。

  5. 5

    再現テストで確かめる

    別ユーザーでIDを指定して叩き、拒否されるかを見ます。

読み取り専用のサブエージェントを定義する

サブエージェントは、.claude/agents/ にMarkdownファイルを置いて定義します。tools を書かないと、サブエージェントは使えるツールをすべて引き継ぎます。書き込み系を持たせないために、許可するツールを明示します。

---
name: idor-auditor
description: ルート定義を列挙し、ID を受け取るハンドラの所有者検証の有無を表で報告する。コードは変更しない
tools: Read, Grep, Glob
model: inherit
---
 
あなたは認可漏れ(IDOR)のレビュー担当です。コードを変更しません。
 
手順:
1. ルート登録を Grep で全件拾い、メソッドとパスを列挙する
2. ハンドラごとに、リソース ID をパスやボディから受け取るかを読む
3. 受け取るなら、所有者またはテナントの検証があるかを読む
   (ハンドラ内、ミドルウェア、ORM のスコープのどれでもよい)
 
出力は次の列の表だけにする:
メソッド | パス | ID の受け取り | 所有者検証 | 根拠(ファイル:行) | 判定
 
判定は「検証あり」「検証なし」「確認できず」の 3 つから選ぶ。
根拠のコードを示せないものは「確認できず」にする。推測で「検証あり」と書かない。

公式のフロントマター一覧では、tools はカンマ区切りの許可リスト、model は inherit を指定できます。ここではBashを渡していません。公式ドキュメントの例でも、Read, Grep, Glob, Bash だけを許可したサブエージェントは「ファイルの編集・書き込みやMCPツールを使えない」とされています。Bashまで外せば、コマンドを実行する経路もなくなります。

Bashを外すと、アプリを起動して叩く確認はできなくなります。この段階で必要なのは読むことだけなので、実行は人と再現テストに分けます。Bashを持たせたまま書き込みだけ止めたいときは、hookで読み取り専用のDB分析役にする手順が参考になります。

サブエージェントを呼び出す

呼び出し方は3通りあります。名前を自然文で伝える、@ で選ぶ、セッション全体をそのサブエージェントにする、です。判定をぶらさず毎回同じ担当に回したいので、@ メンションを使います。

@"idor-auditor (agent)" src/routes と src/controllers を対象に、全ルートの所有者検証を表にして

@ メンションは、どのサブエージェントを動かすかを固定します。サブエージェントへの指示文はClaudeが組み立てるため、対象ディレクトリはメッセージに書いておくと伝わります。

表の形の例

次の表は、後述の再現テストで使う小さなサンプルサーバー(server.js)に対して期待する形の例です。実際の出力は、プロジェクトのコードとモデルによって変わります。

メソッドパスIDの受け取り所有者検証根拠判定
GETパス/ordersIDの受け取りなし所有者検証一覧をownerで絞り込み根拠server.js:20判定対象外
GETパス/orders/:idIDの受け取りパス所有者検証なし根拠server.js:25判定検証なし
PATCHパス/orders/:idIDの受け取りパス所有者検証order.owner !== user で拒否根拠server.js:27判定検証あり
DELETEパス/orders/:idIDの受け取りパス所有者検証なし根拠server.js:31判定検証なし

「IDを受け取らないルート」は、そもそもIDORの対象外です。一覧系はIDの代わりにログイン中のユーザーで絞り込んでいるかを見ます。ここで「対象外」としたのは、絞り込みの根拠が行番号で示されているからです。

ルート一覧の漏れを減らす下ごしらえ

サブエージェントのGrep任せにすると、フレームワークの書き方によって拾い漏れが出ます。先にルート登録の書き方を人が手元で確かめ、検索パターンを指示文に書き足すと一覧がそろいやすくなります。たとえば次のような形です。

# Express 系: app.get / router.patch などの登録行
grep -rnE "(app|router)\.(get|post|put|patch|delete)\(" src
 
# FastAPI 系: デコレーター付きの関数
grep -rnE "@(app|router)\.(get|post|put|patch|delete)\(" app

この件数を、サブエージェントが返した表の行数と突き合わせます。表のほうが少なければ、どこかのファイルを読み飛ばしています。同じパターンは、指示文の「ルート登録をGrepで全件拾い」の下に書いておくと、毎回同じ範囲を探させられます。

人が表を読むときに見る3点

表ができた時点では、まだ結論ではありません。サブエージェントの報告は、人が確かめる材料です。

確認点

表を読み直す観点

  • 「確認できず」の行を先に開く

    ミドルウェアや共通ヘルパーで検証していると、ハンドラ単体には所有者チェックが見えません。根拠を示せない行は、実際に検証が無いのか、別の場所にあるのかを人が追います。

  • 「検証あり」の根拠を 1 件ずつ開く

    示された行が本当に所有者との比較になっているかを見ます。ログイン済みかどうかの確認だけでは、他人のIDを弾けません。

  • 書き込み系を優先する

    PATCHやDELETEが抜けていると、閲覧だけでなく改変や削除に直結します。OWASPも、POST・PUT・DELETEのアクセス制御が欠けたAPIをA01の例に挙げています。

落とし穴

ログイン確認と所有者確認は別物

サンプルサーバーの冒頭には、ヘッダーが無ければ401を返す処理があります。これは「誰か」を確かめるだけで、「そのレコードの持ち主か」は確かめません。認証のコードがあるハンドラを、認可も済んでいると読んでしまうのが典型的な見落としです。

疑わしい行を再現テストで裏づける

表の「検証なし」は、コードを読んだ上での推定です。別ユーザーでIDを指定して叩けば、推定が当たっているかが分かります。サンプルでは、aliceの注文1にbobがアクセスしたら403か404になる、というテストをNode標準のテストランナーで書きました。

const call = (method, path, user) =>
  fetch(base + path, { method, headers: { 'x-user': user } });
 
// 対照: 所有者本人は読める
test('GET /orders/1 は所有者 alice なら 200', async () => {
  assert.strictEqual((await call('GET', '/orders/1', 'alice')).status, 200);
});
 
// 本命: 他人の ID は拒否される
test('GET /orders/:id は他人のIDを拒否する', async () => {
  const r = await call('GET', '/orders/1', 'bob');
  assert.ok([403, 404].includes(r.status), `got ${r.status}`);
});

対照のテストを入れるのは、「拒否された」のが所有者チェックによるのか、そもそもリクエストが壊れているのかを区別するためです。

node --test idor.test.js

Node.js v20.18.0で実行した結果は次のとおりです。

テスト結果実際の応答
GET /orders/1をaliceで結果成功実際の応答200
GET /orders/:idをbobで結果失敗実際の応答200(他人の注文が返った)
PATCH /orders/:idをbobで結果成功実際の応答404
DELETE /orders/:idをbobで結果失敗実際の応答204(他人の注文が消えた)

4件中2件が失敗しました。表で「検証なし」と判定したGETとDELETEの2行と一致しています。PATCHは表どおり「検証あり」で、テストも通ります。

このテストはサンプル用の最小構成です。実プロジェクトでは、テスト用に2人分のユーザーとデータを用意する方法が要ります。Claude Codeにテストの雛形を書かせる場合も、期待するステータスは人が決めてから渡します。404で隠すか403で明示するかはアプリの方針で、サブエージェントには判断できません。

直したら同じテストで確かめる

検証が無いと分かった行は、「IDで取得した後に所有者を比べる」形に直します。サンプルでは、取得直後の判定を次の1行に変えました。他人のIDと存在しないIDが同じ404になるので、IDの存在も漏らしません。

前と後

取得直後の判定を所有者つきにする

BEFORE

直す前

if (!order) return send(res, 404, { error: 'not found' });
AFTER

直した後

if (!order || order.owner !== user)
  return send(res, 404, { error: 'not found' });

同じテストを修正後のサーバーに向けて再実行すると、4件すべてが成功しました(Node.js v20.18.0)。失敗していたGETとDELETEが、1か所の修正で両方とも拒否に変わっています。ハンドラごとに判定を書かずに共通の取得関数へ寄せれば、新しいルートを足したときの書き忘れも減ります。

表を使うときの限界

この手順で分かるのは、コードに所有者検証が見えるかどうかです。次の点は、表だけでは保証できません。

  • ルートが動的に登録される場合、Grepで拾えず一覧から漏れる
  • 検証がミドルウェアやデータベースの行レベルセキュリティにあると、ハンドラ側では「確認できず」になる
  • 「検証あり」と書かれていても、条件分岐の抜けまでは読み切れていない可能性がある

サブエージェントの最終報告は、Claudeに渡る前にClaude Codeがスキャンします。スキャンは文章を消したり書き換えたりせず、報告に混ざった指示の形をした文字列に印を付けるだけです。表の中身が正しいかどうかを判定するものではありません。詳しくはサブエージェント出力スキャンの解説にあります。

網羅性を上げたいときは、同じ表をPRのたびに作り直し、差分だけを人が見る運用が現実的です。マージ前の多角的なレビューはcode-reviewプラグインで回せます。検出された脆弱性の深刻度の付け方は、Claude Securityの判定基準で整理しています。

OWASPが防止策として挙げるのは、アクセス制御を一度だけ実装して使い回すこと、そしてレコードの所有者を強制すること、さらに機能面のアクセス制御テストを単体・結合テストに含めることです。表で穴を見つけたら、修正と同時に、この再現テストをテストスイートに残します。

まとめ

IDORは「ログイン済みかどうか」と「そのレコードの持ち主かどうか」を取り違えたところに生まれます。ルートの全件列挙とハンドラ単位の判定は、読み取り専用のサブエージェントに向いた作業です。人は表のうち「検証なし」と「確認できず」の行だけを開き、再現テストで裏づければ足ります。

テストは落ちるように書いて、修正後に通ることを確かめます。表は修正の進み具合を見る台帳にもなり、直った行は「検証あり」に変わります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/sub-agents
  2. owasp.org/Top10/2021/A01_2021-Broken_Access_Control
この記事を共有:XはてブLinkedIn