AI生成APIのIDOR:Cursorが代わりにチェックしてくれないもの

Dev.to / 2026/4/22

💬 オピニオンDeveloper Stack & InfrastructureSignals & Early TrendsTools & Practical UsageModels & Research

要点

  • CursorのようなAIエディタは、認証は通す一方で、IDでオブジェクトを取得する際に所有権(権限)を検証しないため、IDOR(CWE-639)に脆弱なAPIルートを生成し得ることを示しています。
  • 著者が確認したNode/Expressプロジェクトでは、あるユーザーのJWTでログインした状態でも、URLの注文IDを変えるだけで他人の注文データにアクセスできました。
  • 問題の根本は、データベースのクエリに所有権/認可の条件が欠けている点で、これを追加すれば解消できると述べられています。
  • 著者は、この脆弱なパターンが「雰囲気で作る(vibe-coded)」アプリ全般に広く見られるのは、AIが明示的に要求しない限り認可制約を入れない傾向があるためだと主張しています。

TL;DR

  • AIエディタは、所有権チェックなしでIDによってリソースを取得するルートを生成する――典型的なIDOR(CWE-639)
  • このパターンは「雰囲気(vibe)コード化された」アプリに至る所にある:認証済みの任意のユーザーが、他人のデータを読み取れる
  • DBクエリに追加の1条件を入れると直る――ただしAIは、そう頼まない限りそれを入れない

先月、サイドプロジェクトをレビューしました。Node/Expressのバックエンドで、Cursor生成、構造はきれいで、コメントも丁寧。開発者は自分の認証設定に誇りを持っていました――JWTトークン、bcryptのパスワード、保護されたルート。ちゃんとした内容です。

ところが /api/orders/1 を叩いたところ、ユーザー847としてログインしているのに、ユーザー1の注文が返ってきました。全部です。名前、住所、アイテム、合計。次に /api/orders/2 に切り替えても同じ結果。APIは認証されていました――到達するには有効なJWTが必要です。ですが、それが誰のJWTかは気にしていませんでした。

これはIDORです。Insecure Direct Object Reference(不十分な直接オブジェクト参照)。OWASPはAPI Security Top 10で#1にランク付けしています。そしてAIエディタは、生成するあらゆるリソースのエンドポイントでこれを再現します。

The Vulnerable Pattern

Cursorが「ユーザーの注文を取得するエンドポイント」をプロンプトすると、こう出力されます:

// CWE-639: Authorization Bypass Through User-Controlled Key
app.get('/api/orders/:id', authenticate, async (req, res) => {
  const order = await Order.findById(req.params.id);
  if (!order) return res.status(404).json({ error: 'Not found'});
  res.json(order);
});

認証チェック:はい。所有権チェック:いいえ。

ユーザー42は /api/orders/99 にアクセスすれば、ユーザー99の注文を丸ごと読み取れます。唯一の防御は「正しいIDを知っていること」――そして注文IDは通常、連番の整数か、推測可能なUUIDです。

AIが雑に作っているわけではありません。提示されたタスクを完了しているのです:IDで注文を取得し、認証を要求する。所有権チェックは、プロンプトに入らなかった暗黙のドメイン知識だっただけです。

Why This Keeps Happening

LLMはGitHubとStack Overflowで学習しています。そこにある例は、認証のパターン――ミドルウェア、JWTの検証、セッションチェック――を示しています。所有権チェックはチュートリアルでは比較的レアです。アプリ固有だからです:コードはあなたのデータモデルと、ビジネスルール(「ユーザーは自分の注文にしかアクセスできない」)を知る必要があります。

AIは、あなたのデータモデルを教えてもらわない限り知りません。ほとんどのプロンプトには「そして要求しているユーザーがこのリソースを所有していることを確認してください」が含まれていません。だからAIはそれを飛ばします――怠慢のせいではなく、その制約がプロンプトに書かれていなかったからです。

結果:正しく認証されているのに、完全に未認可。

The Fix

DBクエリに追加の1条件を入れます:

app.get('/api/orders/:id', authenticate, async (req, res) => {
  // Query with userId -- DB-level ownership check
  const order = await Order.findOne({
    _id: req.params.id,
    userId: req.user.id
  });
  if (!order) return res.status(404).json({ error: 'Not found'});
  res.json(order);
});

2つの理由で、別のifチェックを追加するよりも良いです。1つ目は情報漏えいがないこと――403の代わりに404を返すことで、呼び出し元は「ID 99が存在するのか」「それが他人のものか」を確認できません。2つ目は所有権がクエリのレベルで強制されるため、取得後にチェックし忘れる余地がないことです。

Audit Your Existing Routes

簡単なgrepで候補が見つかります:

grep -rn "req.params" src/ | grep "findById\|findOne"

同じクエリに userId または req.user が含まれていないヒットは、IDORである可能性が高いです。それぞれを手作業で確認してください。

Expressに特化するなら:req.params.id、req.params.postId、req.params.orderId、そしてそれに類するものを使っている全てのルートを確認します。クエリがIDパラメータだけを使っていてユーザーIDを使っていない場合、そのエンドポイントはおそらくこのバグがあります。ネストされたリソースも確認してください――/api/posts/:postId/comments/:commentIdは、検証すべき所有権が2階層ある状態です。

私はこのために SafeWeave を運用しています。CursorやClaude CodeにMCPサーバーとして組み込み、先に進む前にこれらのパターンを検出します。とはいえ、semgrepとgitleaksによる基本的なpre-commitフックでも、この投稿にあるものの大半は拾えます。重要なのは、使うツールにかかわらず、早い段階で検出することです。