tva
← コラム

段階的なセキュリティ監査:Next.jsアプリケーションの強化

Webアプリケーションのセキュリティ監査では、技術構成にかかわらず同じ種類の問題が見つかりがちです。認可の漏れ、入力検証の欠如、不十分なHTTPセキュリティヘッダー、認証フローのタイミングに関する脆弱性などです。具体的な現れ方はフレームワークごとに異なりますが、根本のパターンは共通しています。違いを生むのは監査をどれだけ体系的に組み立てるかです。手順の定まらないレビューでは、明白な問題を見つけても、見つけにくい問題を逃しがちです。

私たちは、Supabaseをバックエンドに使う本番のNext.jsアプリケーションで、複数段階の監査を実施しました。このアプリはユーザー認証とユーザーに紐付くレコードの保存を行い、フロントエンドと外部Webhookが使う複数のAPIルートを公開していました。致命的な指摘はありませんでしたが、悪意ある人に発見されれば重大な事故につながる問題がいくつかありました。監査の対象、発見した問題、その結果行った変更を紹介します。

フェーズ1:認証と認可

第1段階では、認証層と、データアクセスを制御する行レベルセキュリティ(RLS)のポリシーを確認しました。Supabaseアプリケーションでは最も重要な制御です。認証が破られたりRLSの設定が誤っていたりすると、入力検証やレート制限をどれだけ加えても補えません。

まずSupabaseの全テーブルとRLSの有効化状況を一覧化しました。公開対象のスキーマ内のすべてのテーブルでRLSを有効にすべきです。Supabaseの既定の権限設定では、publicなどの公開対象のスキーマ内でRLSが無効なテーブルは、anonキーを持つ誰もが読み書きできます。このキーは設計上公開されており、クライアント側のバンドルにも含まれます。このアプリでは1つのテーブルでRLSが無効でした。参照データとして扱っていた検索用テーブルで、作成時には静的な値だけが入っていましたが、その後、保護すべきユーザー関連のメタデータを含む行が追加されていました。RLSを有効にしてポリシーを追加する修正は単純でしたが、手順上の問題は重要です。新規テーブルでは後から対応するのではなく、最初からRLSを有効にすべきです。

次に、保護されたテーブルのRLSポリシーが正しいかを確認しました。よくある誤りは、ユーザーIDの確認自体は正しくても、対象の列が違うことです。例えば、documentsテーブルでauth.uid() = created_byを確認するポリシーは作成者へのアクセス制限として正しく機能します。しかし、文書の共有も可能なら、中間テーブルのdocument_collaboratorsにある共同編集者の行にも独自のポリシーが必要です。主テーブルの確認は、結合先のデータに自動で引き継がれるわけではありません。

コードレビューを通過したポリシーの1つに、ロジックの誤りがありました。USINGとWITH CHECKの両方が必要な場所で、USINGだけを使っていました。USING句はコマンドが参照、更新、削除できる既存の行を決め、WITH CHECKは書き込める新規行や変更後の行を決めます。書き込みを対象とするポリシー(FOR ALLまたはFOR UPDATE)でWITH CHECKを省略すると、PostgreSQLは書き込みにもUSING式を再利用します。そのため、読み取りルールとして記述されたUSING句により、認証済みユーザーは誰でも読み取れる行を書き込めるようになり、意図した動作ではありませんでした。見落としやすいPostgreSQLのRLSの細かな仕様です。

フェーズ2:入力検証

第2段階では、ユーザー入力がシステムへ入るすべての入口を確認しました。app/api/のAPIルートハンドラー、サーバーアクション、フォーム送信が対象です。すべての入力を使用前に検証し、検証に失敗した場合は未処理の例外ではなく、内容の分かるエラーを返すことを確認しました。

アプリでは一部にZodを使っていましたが、統一されていませんでした。完全なスキーマを定義し、ハンドラーの冒頭でschema.parse()を呼ぶルートもあれば、実行時の安全性を保証しないTypeScriptの型アサーションに頼り、検証せずにreq.bodyのフィールドを直接読むルートもありました。

// Unsafe: TypeScript type assertion, no runtime validation
const { userId, documentId } = req.body as { userId: string; documentId: string };

// Safe: Zod parse, throws on invalid input
const schema = z.object({
  userId: z.string().uuid(),
  documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);

対策として、未検証のすべてのAPIルートにZodスキーマを追加し、全ルートが通る共通の検証ミドルウェアを設けました。このパターンは重要です。入力検証の要件を知らずに追加したコード経路から、検証層を迂回できないようにするためです。

あるルートはredirectUrlパラメーターを受け取り、res.redirect()で使っていました。これはオープンリダイレクトの脆弱性です。攻撃者は、一見正当な操作の後でフィッシングサイトに転送する、アプリへのリンクを作れます。対策は、転送先を許可済みパスのリストと照合することです。

const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];

const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
  return res.redirect('/dashboard');
}
return res.redirect(redirectPath);

フェーズ3:HTTPセキュリティヘッダーとCSP

アプリにはContent Security Policyのヘッダーが一切ありませんでした。CSPがないと、ユーザー生成コンテンツや侵害された外部スクリプトを通じてXSS攻撃が成功した場合、認証トークンやユーザーデータにアクセスできるアプリの実行環境で、任意のJavaScriptが実行されます。

Next.jsは既定で一部のスクリプトをインライン化するため、CSPの追加には注意が必要です。推奨される方法はnonceを使うものです。リクエストごとに暗号学的にランダムな値を生成し、CSPヘッダーと各インラインスクリプトに付与します。Next.jsはリクエストヘッダーからポリシーを読み取って自身のスクリプトにnonceを適用するため、本番環境のミドルウェアではCSP(通常はx-nonceヘッダーも)をNextResponse.next({ request: { headers } })で渡す必要があります。Next.jsのCSPガイドにその方法が示されていますが、以下の抜粋はレスポンスヘッダーのみを設定しています。また、Edgeランタイムでは提供されないNodeのcryptoモジュールをインポートしています。EdgeではグローバルのWeb Crypto API(例えばcrypto.randomUUID())を使います。

// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export function middleware(request: Request) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const cspHeader = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'unsafe-inline'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `frame-ancestors 'none'`,
  ].join('; ');

  const response = NextResponse.next();
  response.headers.set('Content-Security-Policy', cspHeader);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
}

最初のCSP導入では、違反を遮断せず報告するContent-Security-Policy-Report-Onlyモードを使いました。これにより、強制モードに切り替える前に許可リストへ加えるべき、正当なインラインスクリプトや外部オリジンを複数確認できました。強制前に1週間、報告専用モードで動かすのが一般的な方法の1つです。この工程を省くと、テストでは見えなかった機能を壊すことがよくあります。

監査では、Strict-Transport-Securityヘッダーをロードバランサーは設定しているものの、アプリは設定していないことも分かりました。入口がロードバランサーだけなら許容できますが、明示的に確認すべき点です。多層防御の考え方では、インフラ側で提供していても、アプリ自身でセキュリティヘッダーを設定すべきです。

フェーズ4:レート制限

ログイン、アカウント作成、パスワード再設定の認証エンドポイントに、レート制限がありませんでした。制限がなければリクエストを無制限に受け付けるため、パスワードへの総当たり攻撃や、応答の違いを利用した有効なメールアドレスの列挙が可能になります。

Next.jsのAPIルートのレート制限は、Redisを使うカウンターで実装できます。以下の例は、スライディングウィンドウより単純な固定ウィンドウを使っています。INCRとEXPIREを別々に呼び出すため、その間に障害が起きると有効期限のないキーが残る可能性があります。本番コードでは、両方の操作をアトミックに実行する必要があります(例えばトランザクションやLuaスクリプトを使います)。通常はIPアドレスをキーにし、認証済みのエンドポイントではユーザーIDを使います。認証用エンドポイントではまだユーザーが認証されていないため、IP単位の制限が適切です。

// lib/rateLimit.ts
import { Redis } from '@upstash/redis';

const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });

export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
  const key = `rate_limit:${identifier}`;
  const count = await redis.incr(key);
  if (count === 1) {
    await redis.expire(key, windowSeconds);
  }
  return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}

適用した制限は保守的なものでした。ログイン試行はIPごとの15分単位で制限し、パスワード再設定はメールアドレスごとの1時間単位で制限します。通常の正当な利用では制限に達することがまれな程度に余裕があり、自動化された総当たり攻撃が現実的でなくなる程度には厳しい設定です。

フェーズ5:タイミング攻撃対策

認証エンドポイントへのタイミング攻撃では、「メールアドレスが見つからない」と「メールアドレスは見つかったがパスワードが違う」の応答時間の差から、アドレスが登録済みかを推測できます。ユーザーが存在する場合にだけbcryptやArgon2によるパスワード比較が実行されるため、見つからない場合より少し時間がかかります。通常50〜200ms程度の差でも、十分な数のリクエストがあれば測定できます。

標準的な対策は、ユーザーが存在しない場合にダミーハッシュを使い、パスワードハッシュの比較を常に実行することです。

const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';

export async function verifyLogin(email: string, password: string) {
  const user = await db.user.findUnique({ where: { email } });

  // Always run bcrypt comparison to normalize response time
  const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
  const isValid = await bcrypt.compare(password, hashToCompare);

  if (!user || !isValid) {
    return null;  // Same code path for both failure cases
  }
  return user;
}

上記のDUMMY_HASHはプレースホルダーです。有効な60文字のbcryptハッシュではなく、bcryptjsなどのライブラリは形式が不正なハッシュに対して即座にfalseを返すため、応答時間を揃える処理が無効になってしまいます。実際には起動時に1回、保存済みハッシュと同じコスト係数で本物のハッシュを生成します(例えばランダムな文字列にbcrypt.hash()を適用します)。問題はSupabase組み込みのログインエンドポイントではなく、既存システムとの連携用にアプリが追加した独自の認証ルートにありました。Supabaseの認証関数を使わない独自ロジックでは、応答時間を揃える処理を明示的に実装する必要があります。

変わったことと変わらなかったこと

5段階の監査で見つかったすべての項目に対応してから、監査を完了としました。優先順位は明確です。RLSの設定ミスとオープンリダイレクトは最優先として即時修正しました。CSP導入とレート制限は高優先度とし、同じスプリントで導入しました。既存連携用ルートのタイミング攻撃対策は中優先度とし、次のスプリントでリリースしました。

監査を受けて、開発手順も変更しました。新しいテーブルでは、プルリクエストをマージする前にRLSポリシーのレビューが必要です。APIルートには共通のZod検証ラッパーを設け、ルートのテンプレートに組み込みました。CSPヘッダーも、インラインの文字列ではなく、一元管理した設定から生成するようにしました。

最も重要な成果は、個々の修正よりも一覧の整備でした。各テーブルにどのデータがあり、どのRLSポリシーが制御し、どのAPIルートがアクセスするかを正確に理解することが、アプリの変化に合わせて正しいセキュリティ判断を下す基盤になります。一覧がなければ、見つけた問題を直す受け身の対応になります。一覧があれば、セキュリティを意図的に検討できる性質として扱えます。

関連記事

関連記事