アクセス制御の不備は、どんなフレームワークのアップグレードにも生き残るバグの種類です。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で実行されている。