はじめに
こんなURLを見たことがあるだろうか。
https://example.com/api/invoices/1042
自分の請求書を表示するページ。ここで末尾の数字を 1043 に変えてアクセスしてみる。
すると、まったく別のユーザーの請求書が表示された。氏名、住所、請求金額、すべて丸見え。
これがIDOR(Insecure Direct Object Reference / 安全でない直接オブジェクト参照)だ。
IDOR はバグバウンティプログラムにおいて最も報告数が多い脆弱性の一つとして知られている。HackerOneが公開したTop 10 Vulnerability Typesでも常に上位に入り、報奨金の総額でも上位を占める。
攻撃に必要な技術はゼロに等しい。ブラウザのアドレスバーを書き換えるだけ。SQLインジェクションのようにペイロードを組み立てる必要もなければ、XSSのようにスクリプトを注入する必要もない。にもかかわらず、影響は壊滅的になりうる。
本稿では、IDORの仕組みをゼロから解説し、なぜ開発中に気づけないのか、どう防ぐのかを、Next.js/Supabase環境の実装例とともに考える。
1. IDORとは何か ― 認可の欠落が生む脆弱性
1.1 基本構造
IDORの本質はシンプルだ。「リクエストに含まれるオブジェクトの識別子を改ざんするだけで、他人のリソースにアクセスできてしまう」こと。
正常なアクセス:
ユーザーA → GET /api/users/100/profile → ユーザーAのプロフィールが返る
IDOR攻撃:
ユーザーA → GET /api/users/101/profile → ユーザーBのプロフィールが返る
認証(authentication)はされている。ユーザーAは正しくログインしている。しかし認可(authorization)がない。「このリソースにアクセスする権限があるか」を確認していない。
認証と認可の違いは、IDORを理解する上で極めて重要だ。認証は「あなたは誰か」を確認すること。認可は「あなたにこの操作が許されているか」を確認すること。IDORは認可が欠落したときに発生する。
1.2 攻撃が発生するパターン
IDORはあらゆる場所で発生する。
| パターン | 攻撃例 | 影響 |
|---|---|---|
| API エンドポイント |
GET /api/orders/1042 のIDを変更 |
他人の注文情報の閲覧 |
| ファイルダウンロード |
GET /files/report-1042.pdf のファイル名を変更 |
機密ファイルの取得 |
| データ更新 |
PUT /api/users/101 で他人のプロフィールを上書き |
データの改ざん |
| データ削除 |
DELETE /api/posts/999 で他人の投稿を削除 |
データの破壊 |
| 管理機能 |
POST /api/admin/users/101/suspend で他人を停止 |
権限昇格 |
1.3 実際の事例
IDORによる大規模インシデントは数多い。
OWASP API Security Top 10では、IDORに相当する「Broken Object Level Authorization (BOLA)」がAPI脆弱性の第1位にランクされている。
2019年に報告された有名な事例では、米国の大手金融サービス企業のAPIで、顧客IDを連番で変えるだけで他の顧客の口座情報にアクセスできた。また、ある航空会社の予約システムでは、予約番号を推測するだけで他人の旅程を閲覧・変更できた。
これらはいずれも、「IDの値を変えてリクエストを送る」というだけの攻撃で成立している。
2. なぜ開発中に気づかないのか ― 構造的な盲点
IDORはなぜこれほど多いのか。開発者が無能だからではない。テストの構造そのものに盲点がある。
2.1 シングルアカウントテストの罠
開発中、エンジニアは自分のテストアカウントでAPIを叩く。
テスト手順:
1. テストアカウントでログイン
2. 自分のデータを作成
3. GET /api/orders/1042 で自分の注文を取得
4. ✅ 正しいデータが返る → テスト通過
ここで見落とされているのは、「別のアカウントで同じリクエストを送ったらどうなるか」だ。自分のアカウントでは常に正しい結果が返るため、認可の欠如に気づかない。
2.2 フレームワークが守ってくれない
認証はフレームワークやミドルウェアが一括で処理してくれることが多い。しかし認可は違う。
認証: ミドルウェアで一括処理できる
→ 「ログインしていないユーザーは全てのAPIを弾く」
認可: エンドポイントごとに個別実装が必要
→ 「この注文はリクエストしたユーザーのものか?」
→ 「この操作はこのユーザーのロールで許可されているか?」
認証は一箇所で設定すれば全体に効く。認可はエンドポイントごとに、リソースごとに判定ロジックを書かなければならない。エンドポイントが100あれば、100箇所で認可チェックが必要だ。1箇所でも漏れれば、そこがIDORになる。
2.3 「動けば正しい」バイアス
機能テストは「正しい入力で正しい結果が返るか」を確認する。しかしセキュリティテストは「不正な入力で不正な結果が返らないか」を確認する。
この視点の切り替えは意識しないと起きない。コードレビューでも、「このAPIは正しく動くか」は確認されても、「このAPIは他人のデータを返さないか」は見落とされやすい。
3. 脆弱なコードと安全なコード ― Before / After
3.1 APIルート: 注文情報の取得
Before(脆弱)
// app/api/orders/[id]/route.ts — 脆弱な実装
import { createClient } from "@/lib/supabase/server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const supabase = await createClient();
// 認証チェックはある
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ❌ 認可チェックがない — IDさえ分かれば誰の注文でも取得できる
const { data: order } = await supabase
.from("orders")
.select("*")
.eq("id", id)
.single();
return NextResponse.json(order);
}
このコードは認証(ログイン済みか)は確認しているが、認可(この注文がリクエスト者のものか)を確認していない。ログインさえしていれば、IDを変えるだけで誰の注文でも取得できる。
After(安全)
// app/api/orders/[id]/route.ts — 安全な実装
import { createClient } from "@/lib/supabase/server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// ✅ 認可チェック: user_id が一致する注文のみ取得
const { data: order, error } = await supabase
.from("orders")
.select("*")
.eq("id", id)
.eq("user_id", user.id) // リソースオーナーシップの検証
.single();
if (error || !order) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(order);
}
存在しない注文と権限のない注文の両方に対して 404 を返しているのは意図的だ。403 を返すと「その注文は存在するが権限がない」という情報を攻撃者に与えてしまう。これを情報列挙(enumeration)という。
3.2 Server Actions: プロフィール更新
Before(脆弱)
// app/actions/profile.ts — 脆弱な実装
"use server";
import { createClient } from "@/lib/supabase/server";
export async function updateProfile(userId: string, displayName: string) {
const supabase = await createClient();
// ❌ クライアントから渡された userId をそのまま使っている
const { error } = await supabase
.from("profiles")
.update({ display_name: displayName })
.eq("id", userId);
if (error) throw new Error("Update failed");
}
クライアントから送られた userId を信頼している。攻撃者は他人の userId を指定してプロフィールを上書きできる。
After(安全)
// app/actions/profile.ts — 安全な実装
"use server";
import { createClient } from "@/lib/supabase/server";
export async function updateProfile(displayName: string) {
const supabase = await createClient();
// ✅ セッションからユーザーIDを取得(クライアントを信頼しない)
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
const { error } = await supabase
.from("profiles")
.update({ display_name: displayName })
.eq("id", user.id); // セッションのユーザーIDを使用
if (error) throw new Error("Update failed");
}
Server Actionsでもクライアントからの入力は信頼できない。Server Actionsは「サーバーで実行される」だけで、引数はクライアントから送られてくる。攻撃者はブラウザのDevToolsやcurlで任意の値を送信できる。
4. 防御パターン
4.1 認可チェックの徹底
最も基本的で最も重要な対策。すべてのリソースアクセスで「リクエスト者がそのリソースにアクセスする権限があるか」を検証する。
// lib/auth/authorize.ts — 認可ヘルパー
import { SupabaseClient, User } from "@supabase/supabase-js";
export async function authorizeResourceOwner(
supabase: SupabaseClient,
user: User,
table: string,
resourceId: string
): Promise<boolean> {
const { data } = await supabase
.from(table)
.select("id")
.eq("id", resourceId)
.eq("user_id", user.id)
.single();
return data !== null;
}
4.2 UUIDの使用
連番IDは推測が容易だ。1042 の次は 1043 だと誰でも分かる。UUIDにすれば推測は事実上不可能になる。
連番ID: /api/orders/1042 → 次は /api/orders/1043
UUID: /api/orders/a1b2c3d4-e5f6-7890-abcd-ef1234567890
→ 推測不可能(2^122通り)
ただし、UUIDは認可チェックの代替にはならない。
UUIDを使っても認可チェックは必須だ。UUIDは「推測しにくくする」だけで「アクセスを制御する」わけではない。URLが共有されたり、ログに記録されたり、ブラウザ履歴に残ったりすれば、UUIDは漏洩する。多層防御(defense in depth)の考え方で、UUIDと認可チェックの両方を実装すべきだ。
4.3 クライアントからのIDを信頼しない
リソースの所有者を判定するための情報は、必ずサーバーサイドのセッションから取得する。
// ❌ クライアントから送られたユーザーIDを信頼する
const userId = requestBody.userId;
// ✅ セッションからユーザーIDを取得する
const { data: { user } } = await supabase.auth.getUser();
const userId = user.id;
5. Next.js × Supabase環境での実装 ― RLSとの組み合わせ
Supabaseには行レベルセキュリティ(Row Level Security / RLS)という強力な仕組みがある。これをIDOR対策の「最後の砦」として使う。
5.1 RLSポリシーの設定
-- ordersテーブルのRLSを有効化
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- SELECT: 自分の注文のみ閲覧可能
CREATE POLICY "Users can view own orders"
ON orders FOR SELECT
USING (auth.uid() = user_id);
-- UPDATE: 自分の注文のみ更新可能
CREATE POLICY "Users can update own orders"
ON orders FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
-- DELETE: 自分の注文のみ削除可能
CREATE POLICY "Users can delete own orders"
ON orders FOR DELETE
USING (auth.uid() = user_id);
-- INSERT: 自分のuser_idでのみ作成可能
CREATE POLICY "Users can create own orders"
ON orders FOR INSERT
WITH CHECK (auth.uid() = user_id);
RLSが有効な状態では、アプリケーションコードに認可チェックの漏れがあっても、データベースレベルで不正アクセスがブロックされる。
5.2 多層防御の実装
RLSだけに頼るのではなく、アプリケーション層とデータベース層の両方で防御する。
// app/api/orders/[id]/route.ts — 多層防御の実装
import { createClient } from "@/lib/supabase/server";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// 第1層: アプリケーション層の認可チェック
const { data: order, error } = await supabase
.from("orders")
.select("*")
.eq("id", id)
.eq("user_id", user.id)
.single();
// 第2層: RLSが有効なので、仮に .eq("user_id", ...) を
// 書き忘れても、他人の注文は返されない
if (error || !order) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json(order);
}
多層防御の構造:
リクエスト
↓
[認証ミドルウェア] ← ログイン済みか確認
↓
[アプリケーション層] ← .eq("user_id", user.id) で認可チェック
↓
[RLS(データベース層)] ← auth.uid() = user_id で最終防御
↓
レスポンス
5.3 Middleware での認証一括処理
Next.jsのMiddlewareで認証を一括処理し、認可はルートハンドラーで個別に実装する。
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options);
});
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
// 認証が必要なパスへの未認証アクセスをリダイレクト
if (!user && request.nextUrl.pathname.startsWith("/api/")) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return response;
}
export const config = {
matcher: ["/api/:path*", "/dashboard/:path*"],
};
6. IDORチェックリスト
開発・レビュー時に確認すべき項目をまとめる。
API設計時
- すべてのエンドポイントで、リソースの所有者とリクエスト者の一致を検証しているか
- クライアントから送られたユーザーIDではなく、セッションからユーザーIDを取得しているか
- 連番IDではなくUUIDを使用しているか
- 権限のないリソースへのアクセスに403ではなく404を返しているか
Supabase / データベース
- すべてのテーブルでRLSが有効化されているか
- SELECT / INSERT / UPDATE / DELETE それぞれにポリシーが設定されているか
-
service_roleキーがクライアントに露出していないか(RLSをバイパスする)
テスト
- 異なるユーザーアカウントでのクロスアカウントテストを実施しているか
- IDを連番で変えてアクセスするテストを自動化しているか
- 水平権限昇格(同じロールの他ユーザーのデータへのアクセス)をテストしているか
- 垂直権限昇格(上位ロールの機能へのアクセス)をテストしているか
コードレビュー
- 新しいAPIエンドポイントに認可チェックがあるか
-
.eq("user_id", user.id)相当の条件がクエリに含まれているか - Server Actionsの引数にユーザーIDやリソースIDが含まれていないか
おわりに
IDORは、技術的に高度な攻撃ではない。URLの数字を1つ変えるだけ。しかし、その影響は他人の個人情報の閲覧からデータの改ざん・削除まで、致命的になりうる。
そしてIDORが厄介なのは、開発者が「自分のアカウントでテストする」という自然な行動をとる限り、気づきにくいことだ。攻撃者の視点 ―「別のユーザーとして同じリクエストを送ったらどうなるか」― を持たなければ、脆弱性は見えない。
防御の原則はシンプルだ。
- すべてのリソースアクセスで認可チェックを行う
- ユーザーIDはセッションから取得し、クライアントを信頼しない
- UUIDを使って推測を困難にする
- SupabaseのRLSでデータベース層の最終防御を敷く
- クロスアカウントテストを習慣にする
完璧な防御は存在しない。しかし、多層防御を意識し、チェックリストを運用するだけで、IDORのリスクは大幅に低減できる。「URLの数字を変えるだけ」の攻撃に、システムを壊されないようにしよう。
参考文献
- OWASP API Security Top 10 - Broken Object Level Authorization
- OWASP - Insecure Direct Object Reference
- HackerOne - Top 10 Vulnerability Types
- PortSwigger - Insecure Direct Object References (IDOR)
- Supabase - Row Level Security
- Next.js - Middleware
- CWE-639: Authorization Bypass Through User-Controlled Key