本文へスキップ
CodeAuditAgent
すべての記事

IDORとアクセス制御の不備:実践ガイド

IDORとアクセス制御の不備がREST・GraphQL・Next.jsのルートハンドラーに入り込む経路と、テスト方法、実際に通用する修正策。

· 7分で読めます · Lina Source LLC

アクセス制御の不備は、どんなフレームワークのアップグレードにも生き残るバグの種類です。ORMはSQLをエスケープし、テンプレートエンジンはHTMLをエスケープしてくれますが、請求書4812がBobではなくAliceのものであることは、スタックのどこも知りません。その知識はコードの中にしかなく、あるハンドラーが適用を忘れた瞬間、ログイン済みのどのユーザーでも他人のデータを読んだり書き換えたりできてしまいます。

最も多い形が、安全でない直接オブジェクト参照、すなわちIDORです。クライアントが識別子を送り、サーバーがその識別子でレコードを読み込み、呼び出し元にそれを見る権限があるかを誰も確認しません。悪用に特別なツールは要りません。ブラウザと2つ目のアカウント、そしてURLの数字を書き換えるだけで十分です。危険な関数呼び出しを探すスキャナーではほとんど検出できません。脆弱なコードには危険な要素が何もなく、ごく普通のデータベース参照から条件が1つ抜けているだけだからです。

よく見かける3つのCWE

  • CWE-639、ユーザーが制御するキーによる認可バイパス:典型的なIDORです。攻撃者が制御できるIDでレコードを選択し、所有者の確認が行われません。
  • CWE-862、認可の欠如:ハンドラーが認可チェックを一切行っていません。到達されないはずだと想定された管理用・内部用のエンドポイントに多く見られます。
  • CWE-285、不適切な認可:チェックは存在するものの誤っています。別のフィールドを見ている、書き込みに対して読み取り権限を確認している、クライアントから送られたロールを信頼している、といったケースです。

この区別は修正時に意味を持ちます。チェックの欠如なら追加すれば済みますが、不適切なチェックは「誰が何をできるか」というモデル自体が誤っているということであり、同じ間違いが他の場所でも繰り返されている可能性が高いのです。どちらを見つけた場合も、チケットを閉じる前に同類の箇所を探してください。アクセス制御のバグが単発であることはまれで、チームがハンドラーからハンドラーへコピーしたパターンに沿って現れます。

Next.jsのルートハンドラーで起きる経緯

最もよくある形を見てみましょう。ハンドラーはユーザーを認証しており、それがセキュリティのように感じられますが、その後レコードをIDだけで読み込んでいます。セッションの確認が答えているのは「誰が呼び出しているか」だけで、「この呼び出し元がこの請求書を見てよいか」には何も答えていません。

// app/api/invoices/[id]/route.ts  (脆弱な例)
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;
  // IDを変えるだけで、ログイン中のどのユーザーも任意の請求書を読めてしまう
  const invoice = await db.invoice.findUnique({ where: { id } });
  return NextResponse.json(invoice);
}

修正の要点は、所有権の確認を「忘れうる別の手順」ではなく、クエリそのものの一部にすることです。レコードが呼び出し元のものでなければデータベースは何も返さず、ハンドラーは404を返します。

// app/api/invoices/[id]/route.ts  (修正後)
export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> }
) {
  const session = await auth();
  if (!session) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  const { id } = await params;
  const invoice = await db.invoice.findFirst({
    where: { id, userId: session.user.id },
  });
  if (!invoice) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return NextResponse.json(invoice);
}

403ではなく404を返すのは意図的です。403はレコードが存在することを確認させてしまい、読めない場合でも有効なIDを総当たりで特定できるようになります。応答時間やエラーメッセージについても同じです。他人のレコードに対する応答は、そもそも存在しなかったレコードに対する応答と区別がつかないようにすべきです。

REST:見落とされがちなエンドポイント

多くのチームは、目につきやすいID指定のGETは保護しています。バグが潜むのは、他のメソッドとAPIの周辺部です。

  • 所有権チェックが追加される前のGETハンドラーからコピーされたPATCHやDELETEのハンドラー。
  • /projects/:projectId/tasks/:taskId のようなネストしたルート。プロジェクトは確認されるのに、タスクはtaskIdだけで読み込まれ、別のプロジェクトに属している可能性があります。
  • IDの配列を受け取り、最初の1件しか確認しない一括処理エンドポイント。
  • ファイルダウンロードやエクスポート処理。独自の緩いチェックしか持たない別サービス経由で動くことがよくあります。
  • リクエストボディのownerId、organizationId、roleをそのまま受け取ってデータベースに書き込む更新処理(マスアサインメント)。

GraphQLでは攻撃面がさらに広がる

GraphQLでは、同じオブジェクトに複数の経路から到達できます。invoice(id) のクエリレベルでチェックしても、同じ請求書が customer { invoices } や node(id) 参照、ミューテーションの戻り値の型からも到達できるなら意味がありません。オブジェクトを返すすべてのリゾルバーが入口です。DataLoaderのようなバッチ処理層はもう1つの落とし穴です。IDだけをキーにしたローダーは、どの閲覧者に対してもレコードを返してしまいますし、リクエスト間で共有されていれば、そのキャッシュが後続のリクエストに別ユーザーのデータを返すこともあります。

確実なアプローチは、リゾルバー自身ではなく、リゾルバーが呼び出すデータ層で認可することです。請求書へのすべての経路が、閲覧者を受け取ってクエリを絞り込む1つの関数を通るなら、新しいフィールドやリレーションを追加してもそれを迂回できません。ミューテーションの入力も確認してください。入力型のownerIdのようなフィールドは、レコードの付け替えを招く隙です。最後に、イントロスペクションやエラーメッセージがスキーマを明らかにすることを忘れないでください。公開しているフィールドとリレーションはすべて攻撃者に知られていると考えるべきです。

Pythonでも同じバグ

FastAPIとSQLAlchemyの組み合わせでも形はまったく同じです。脆弱なバージョンは db.get(Document, doc_id) を呼び、修正版は同じ文の中で所有者による絞り込みを行います。

from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session

app = FastAPI()

@app.get("/documents/{doc_id}")
def get_document(
    doc_id: int,
    user: User = Depends(current_user),
    db: Session = Depends(get_db),
):
    # 脆弱な例: doc = db.get(Document, doc_id)
    doc = db.scalar(
        select(Document).where(
            Document.id == doc_id,
            Document.owner_id == user.id,
        )
    )
    if doc is None:
        raise HTTPException(status_code=404, detail="Not found")
    return doc

実際に通用する修正策

すべてのクエリを所有者またはテナントで絞り込む

すべての読み取りと書き込みのWHERE句に、ユーザーIDまたは組織IDを入れてください。こうすると認可がクエリの性質になり、レビューでも見つけやすくなります。マルチテナントのアプリケーションでは、Postgresの行レベルセキュリティを2層目としてテナント境界の強制に使えるため、絞り込みを忘れても他社の行ではなく空の結果が返ります。同じ絞り込みは書き込みにも当てはまります。更新は、読み取り・確認・別の書き込みという競合の起きうる手順ではなく、IDと所有者の両方で絞り込んだ1つの文、たとえば両方の条件を指定したupdateManyの後に変更行数がちょうど1件かを確認する形にすべきです。

判断を一箇所に集約する

あちこちに散らばったif文は次第にずれていきます。リソースごとに1つずつ、小さなヘルパー群を用意すればルールが一箇所にまとまり、ヘルパーを呼んでいないハンドラーが目立つようになります。

// lib/authz.ts
type Role = "owner" | "member" | "viewer";
type Action = "read" | "update" | "delete";

const policy: Record<Role, ReadonlySet<Action>> = {
  owner: new Set<Action>(["read", "update", "delete"]),
  member: new Set<Action>(["read", "update"]),
  viewer: new Set<Action>(["read"]),
};

export class NotFoundError extends Error {}

export async function requireProject(
  userId: string,
  projectId: string,
  action: Action
) {
  const membership = await db.membership.findFirst({
    where: { userId, projectId },
    include: { project: true },
  });
  // 既定で拒否:メンバーでない場合も権限がない場合も同じ見え方にする
  if (!membership || !policy[membership.role as Role]?.has(action)) {
    throw new NotFoundError();
  }
  return membership.project;
}

既定で拒否する

未知のロール、存在しないメンバーシップ、想定外のアクションは、すべて拒否に落ちるようにします。ミドルウェアのあるフレームワークでは、すべてに認証を要求したうえで公開ルートを明示的に指定してください。逆ではありません。新しいルートは、誰かが別の判断を下すまでロックされているべきです。ロール、テナント、ユーザーIDをリクエストボディやクライアントが設定したヘッダーから取ってはいけません。毎回サーバー側で検証済みのセッションから導出してください。

UUIDのようなランダムな識別子を使うことには価値がありますが、それは修正ではありません。IDはURL、ログ、共有リンク、リファラーヘッダーから漏れます。推測しにくいというのは総当たりを遅らせるという意味にすぎず、決してアクセスチェックの代わりにはなりません。

テストの方法

IDORのテストは単純で反復的です。だからこそ、一度手作業でやったあとは自動化する価値があります。まず棚卸しから始めましょう。識別子を受け取るすべてのルート、リゾルバー、バックグラウンドジョブを、リクエストボディやクエリ文字列、ヘッダーに隠れたIDも含めて洗い出します。

  • アカウントAとBの2つを、できれば別々の組織に作ります。Aとしてレコードを作成し、そのIDを控えます。
  • そのIDを参照するすべてのリクエストを、Bのセッションで再送します。GET、PATCH、DELETE、ダウンロード、エクスポート、そのレコードに触れるあらゆるGraphQLのクエリとミューテーションが対象です。
  • すべてで404が返ることを期待します。200が返った場合、および存在を確認させてしまう403は、いずれも指摘事項です。
  • 手作業のチェックをリソースごとの結合テストに変え、絞り込みのないハンドラーが追加されたらCIで落ちるようにします。
  • findUnique({ where: { id } }) や db.get(Model, id) のように主キーだけで参照している箇所をgrepし、それぞれに正当な理由があるか確認します。

テストで拾えないものはコードレビューが拾います。そのルートにテストを書いた人がいなくても、チェックの欠如はソースコード上で目に見えるからです。CodeAuditAgentは公開GitHubリポジトリや貼り付けられたスニペットを読み取り、アクセス制御の不備をCWE、該当行の引用、攻撃シナリオ、修正パッチ案とともに報告します。すべてのハンドラーをまとめてもう一度確認する手軽な方法です。

短いチェックリスト

  • クライアントから渡されたIDを使うクエリは、必ず呼び出し元のユーザーまたはテナントでも絞り込んでいる。
  • 認可は共有ヘルパーかデータ層にあり、コピー&ペーストされたif文の中にはない。
  • 未知のケースは拒否し、公開ルートは明示的な例外として扱っている。
  • 書き込み処理も読み取りと同じ厳密さで確認しており、一括処理やネストしたルートも対象にしている。
  • リソースごとに2アカウントのテストが存在し、CIで実行されている。