Next.js Server Actionsは、use-serverファイル内でエクスポートされたすべての関数を、公開HTTPエンドポイントとして自動的に公開します。クライアントはその関数を呼び出すために、割り当てられた一意の識別子を使用します。ボタンやリンク、その他のUI要素がレンダリングされているかどうかにかかわらず、そのエンドポイントは存在するため、識別子を特定した人は誰でもその関数を直接呼び出すことができます。

フレームワークの設計上、Server Actionsは通常のヘルパー関数のように扱われますが、実行時にはアクセス可能なURLとなります。UIだけがゲートキーパー(門番)であると考えていた開発者にとって、これは単一の細工されたリクエストによって悪用され得る、目に見えない攻撃対象領域(アタックサーフェス)を生み出します。

なぜServer Actionsは安全に見えたのか

Server Actionsは、開発者がコンポーネントのすぐ隣にサーバーサイドのコードを記述できるように、個別のAPIルートを作成する手間(ボイラープレート)を省くために導入されました。典型的な使い方は以下の通りです。

<form action={deleteInvoice}>
  <button type="submit">Delete</button>
</form>

フォームはdeleteInvoiceをトリガーする唯一の目に見える方法であるため、多くの開発者は、権限のないユーザーに対してボタンを非表示にしたり、TypeScriptの型定義が不正なデータを防いでくれると考えたり、関数がサーバー専用モジュール内にあるという事実に依存したりします。しかし、これらの仮定のいずれも、真の保護を提供するものではありません。

隠れた露出

ファイルに“use server”が含まれている場合、Next.jsはエクスポートされた各関数を次のようなエンドポイントにコンパイルします。

POST /_next/data/<build-id>/<page>.json?__rsc=<action-id>

<action-id>は、クライアントのバンドルに埋め込まれる安定したハッシュです。攻撃者は以下の方法でこれを取得できます。

  • ページのネットワークトラフィックを調査する。
  • バンドルされたJavaScriptを読み取る(IDはプレーンな文字列です)。
  • プロジェクトが予測可能なパターンに従っている場合、命名規則に基づいて推測する。

一度IDが判明すれば、cURL、Postman、または悪意のあるスクリプトなど、あらゆるツールからリクエストを送信して、UIレベルのチェックをバイパスすることが可能になります。

3つの具体的なリスク

リスク なぜ重要なのか
UIチェックは効果がない ボタンやリンクを非表示にしても、基盤となるエンドポイントは削除されません。隠された管理ページがサーバー上に存在し続けるのと同様に、エンドポイントはアクセス可能なままです。
TypeScriptは実行時の安全性を提供しない コードの実行時には型は削除されます。deleteInvoice(id: number)として宣言された関数であっても、巨大な文字列、配列、あるいは悪意のあるJSONを受け取ることができ、ロジックエラーやインジェクション攻撃につながる可能性があります。
デフォルトの認証や認可がない Server Actionsはローカルのヘルパー関数のように見えるため、開発者は従来のAPIルートで標準的なセッションチェック、CSRF保護、または行レベルの権限チェックを追加することを忘れがちです。

Server Actionを保護する方法

  1. 呼び出し元を認証する – ビジネスロジックが実行される前に、有効なセッションまたはトークンが存在することを確認します。
  2. ペイロードを検証する – スキーマライブラリ(例:Zod、Yup)を使用して、実行時にデータ型や値の制約を強制します。
  3. 操作を認可する – 「ユーザーはログインしているか?」だけでなく、ユーザーが変更または削除しようとしている特定のレコードの所有者であることを確認します。

最小限の例:

'use server';
import { getSession } from '@/auth';
import { z } from 'zod';
import { db } from '@/db';

const DeleteInvoiceSchema = z.object({
  id: z.number().int().positive(),
});

export async function deleteInvoice(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error('Unauthenticated');

  const parsed = DeleteInvoiceSchema.safeParse({
    id: Number(formData.get('id')),
  });
  if (!parsed.success) throw new Error('Invalid input');

  const invoice = await db.invoice.findUnique({ where: { id: parsed.data.id } });
  if (!invoice || invoice.ownerId !== session.userId) {
    throw new Error('Unauthorized');
  }

  await db.invoice.delete({ where: { id: invoice.id } });
}

このコードでは、認証を明示的にチェックし、渡されたidを検証し、削除を実行する前にログインユーザーが実際にその請求書(invoice)の所有者であることを確認しています。

その他の巧妙なNext.jsの落とし穴

問題 症状 修正方法
NEXT_PUBLIC_ 環境変数 NEXT_PUBLIC_ で始まるものはすべてクライアントにバンドルされ、機密情報が露出します。 機密情報はプレーンな環境変数に保持し、決して NEXT_PUBLIC_ をプレフィックスとして付けないでください。
オープンリダイレクト redirect クエリパラメータを受け取り、それをそのまま結合すると、ユーザーを //evil.com に飛ばしてしまう可能性があります。 ホワイトリストに対してターゲットを検証するか、同一オリジンチェックを強制します。
Server-Side Request Forgery (SSRF) ユーザーが提供したURLを取得すると、攻撃者が内部サービスやクラウドのメタデータエンドポイントに到達できてしまう可能性があります。 ホスト名を許可リスト化し、プライベートIP範囲をブロックし、タイムアウトを設定します。
dangerouslySetInnerHTML サニタイズせずにユーザー提供のHTMLをレンダリングすると、XSSの脆弱性が生まれます。 DOMPurifyのようなライブラリを使用するか、生のHTMLの使用を完全に避けます。

これらのパターンを自動的にスキャンする

静的解析ツールを使用すると、上記の危険な構成をフラグ立てできます。軽量なオプションの一つとして、高速に動作しCIパイプラインに統合可能なSemgrepがあります。

npx --yes semgrep --config https://raw.githubusercontent.com/catidegla/stacksec/main/rules .

このルールセットには、エクスポートされたサーバー関数、NEXT_PUBLIC_ の誤用、オープンリダイレクト、SSRFパターン、および安全でないHTML挿入のチェックが含まれています。

次に注目すべきこと

  • フレームワークのアップデート – Next.jsのリリースに注意してください。チームが組み込みの認証フックを導入したり、生成されたエンドポイントをサンドボックス化したりする可能性があります。
  • コミュニティツール – ここで説明した保護策を自動化するために、新しいESLintプラグインやNext.js特有のSemgrepルールが登場しています。
  • 実世界のインシデント – より多くのプロジェクトがServer Actionsを採用するにつれ、実際のリスクを示す公開されたエクスプロイトに注意してください。早期に検知することで、侵害が発生する前に内部のセキュリティレビューに役立てることができます。

まとめ: Next.jsのServer Actionはプライベートなヘルパーではありません。エクスポートした瞬間に、それは公開されたHTTPエンドポイントとなります。他のAPIルートと同様に、認証、検証、認可を行ってください。さもなければ、UIのすぐ隣にサーバーコードを書けるという利便性が、瞬く間にセキュリティ上のリスクへと変わりかねません。