Serverless の状態漏えい:ユーザーデータをリクエストに限定
再利用される serverless runtime が古いユーザーデータを露出する条件と、ID・状態をリクエスト単位で分離する方法を説明します。
💡 やさしい説明
認証情報、tenant ID、リクエスト固有データは来訪者バッジのようなものです。一度の訪問に属し、共有の作業台に置き忘れてはいけません。一部の serverless runtime は後続リクエストで実行環境や isolate を再利用します。モジュールスコープの可変状態にユーザーコンテキストを保存し、別のリクエストが読むと、アプリ自身のユーザー・tenant 境界を越えるおそれがあります。
再利用は実装上起こり得る動作であり、次のユーザーが同じ runtime に到達する保証でも、一定時間稼働する保証でもありません。漏えいには、共有実行状態とリクエストデータを残す、または古い状態を読むコード経路の両方が必要です。ユーザー ID はリクエスト内に保持し、必要な処理へ明示的に渡してください。
主な概念と用語
- Execution environment reuse
- 実行環境の再利用:プラットフォームは後続の呼び出しに備えて runtime や isolate を維持する場合がありますが、いつでも破棄できます。
- Module-level state
- モジュールスコープ状態:handler の外で初期化された値。同じ runtime 内の呼び出し間で残る場合があるため、ユーザー固有データを保持してはいけません。
- AsyncLocalStorage
- AsyncLocalStorage:非同期実行コンテキスト内で store を伝播する Node.js API。コンテキストを追跡するもので、ユーザーを認証・認可する機能ではありません。
- Cross-request data exposure
- リクエスト間のデータ露出:再利用コンテキストで後続リクエストが前のデータを読む失敗。再利用だけでは漏えいの証明になりません。
データ露出の流れ
最初のリクエストが固有データを作る
リクエスト A は認証し、自身のユーザーまたは tenant コンテキストを解決します。handler 内に保持せず、モジュールスコープの可変変数へ書き込むと問題になります。
runtime が再利用される場合がある
応答後もプラットフォームが環境を維持することがあります。AWS は環境再利用を説明しています。Cloudflare Workers も isolate を再利用する場合がありますが、ユーザー間のルーティング順や寿命は固定されません。
後続リクエストが同じ runtime に届く
リクエスト B が同じプロセスまたは isolate で処理されると、A が残したモジュール値は上書き・消去されるまで利用可能です。早期 return や認証漏れによって古い状態を参照する場合があります。
誤ったユーザー ID でデータを返す
handler が古い値で DB を検索したり応答を作ったりすると、B に A のデータが返る可能性があります。これは条件付きのアプリ分離不備であり、serverless の必然ではありません。
ソースコード:危険な例と安全な実装
// UNSAFE: request identity is mutable process-wide state.
import { NextResponse } from "next/server";
let cachedUserContext: { userId: string; role: string } | null = null;
export async function POST(req: Request) {
const token = req.headers.get("authorization");
if (token) {
cachedUserContext = await decodeUser(token);
}
// If token is missing, stale state can still be used.
return NextResponse.json({ actor: cachedUserContext?.userId });
}
// SAFE PATTERN: keep identity local and pass it to the service.
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const user = await authenticate(req);
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const account = await loadAccountForUser(user.id);
return NextResponse.json({ actor: user.id, account });
}
// The service enforces ownership in its query:
async function loadAccountForUser(userId: string) {
return db.account.findFirst({ where: { ownerId: userId } });
}
エンジニアリング強化チェックリスト
- 認証情報、ユーザー ID、tenant ID、リクエスト固有の可変データは handler または明示的なリクエストスコープ内に保持し、モジュールグローバル変数に保存しないでください。
- 認証済み ID はサービスやデータアクセス関数へ明示的な引数で渡してください。Node.js の
AsyncLocalStorage.run()は暗黙の非同期コンテキストが必要な場合に限り使います。これはコンテキスト伝播であり、認可ではありません。 - DB クライアントの singleton をユーザー状態と混同しないでください。クライアント再利用は可能ですが、各クエリに認証済み user/tenant を渡し、条件で所有権を強制します。
React cache()は React Server Components のドキュメントどおりリクエスト単位のメモ化にのみ使い、汎用のリクエスト間認証キャッシュとして使わないでください。- 異なるユーザーと未認証のリクエストを交錯させる統合テストを追加し、各応答と DB 操作が対応する ID に紐づくことを確認してください。
- プラットフォームのライフサイクル文書を確認します。実行環境が再利用される前提でロジックをテストしつつ、どのリクエストがどのインスタンスに届くかは仮定しないでください。