はじめに:Server Actions は「公開 API エンドポイント」である
Next.js App Router の Server Actions、使ってますか? "use server" と書くだけでクライアントからサーバーの関数を直接呼べるあの手軽さ、正直めちゃくちゃ便利ですよね。フォーム送信やデータ更新がこんなにシンプルに書けるなんて、初めて触ったとき感動しました。
でも、この「手軽さ」にこそ大きな落とし穴があります。
Server Actions として export された関数は、すべて公開 HTTP POST エンドポイントになります。ブラウザの DevTools からでも、curl コマンド一発でも、好きなペイロードを送り込めてしまうんです。「え、そうなの?」と思った方、けっこう多いんじゃないでしょうか。
実際、2025年末には CVSS 10.0(最高スコア)の致命的な脆弱性 CVE-2025-55182(通称 React2Shell)が発見されて、デフォルト設定の Next.js アプリが認証なしでリモートコード実行されるという衝撃的な事態が起きました。この記事では、こうした脆弱性の概要と、Server Actions を本番環境で安全に動かすための実践的なセキュリティ対策を、動くコード例とともに解説していきます。
2025年に発覚した重大な脆弱性
CVE-2025-55182(React2Shell)— CVSS 10.0
2025年11月29日に Lachlan Davidson 氏によって報告され、12月3日に公開されたこの脆弱性。React Server Components のデシリアライズ処理に起因するもので、サーバーが RSC ペイロードを受信する際のモジュールエクスポート解決ロジックに問題がありました。結果として、認証なしで任意のコードをサーバー上で実行できてしまうという、かなり深刻なものです。
影響を受けるバージョンはこちら。
- Next.js:15.0.0 〜 16.0.6
- React:19.0.0(Server Components 使用時)
特に怖いのは、create-next-app で作ったそのままのアプリがこの脆弱性を持っているという点。細工した HTTP リクエストを1つ送るだけで悪用できるため、ワークアラウンドは存在せず、パッチ適用が唯一の対策です。これは本当に「今すぐアップデートしてください」案件です。
修正済みバージョンは以下のとおりです。
- React:19.0.1、19.1.2、19.2.1(さらに 19.0.3、19.1.4、19.2.3 で追加修正あり)
- Next.js:15.0.5、15.1.9、15.2.6、15.3.6、15.4.8、15.5.7、16.0.7
パッチ適用後は、アプリケーションのすべてのシークレット(API キー、データベース接続文字列など)をローテーションすることが強く推奨されています。パッチを当てただけで安心しないでくださいね。
CVE-2025-66478 — 追加の RCE 脆弱性
React2Shell の公開後、React Server Components のさらなる調査が進み、CVE-2025-66478(同じくデシリアライズ起因の RCE、CVSS 10.0)が追加で発見されました。上記のパッチバージョンにはこの修正も含まれているので、まとめてアップデートすれば大丈夫です。
CVE-2024-56332 — DoS / DoW 攻撃
もう一つ知っておくべきなのがこの脆弱性。Server Actions を利用する環境で、細工したリクエストにより関数実行をキャンセルせずに接続を長時間維持させることができてしまいます。これにより、サービスを落とす DoS 攻撃や、クラウドの請求額を爆増させる DoW(Denial of Wallet)攻撃に悪用されるおそれがあります。(個人的にはこの DoW が一番怖い...)
Server Actions の5大セキュリティリスクと対策
さて、ここからが本題です。Server Actions で気をつけるべき5つのセキュリティリスクと、それぞれの具体的な対策を見ていきましょう。
1. 入力バリデーションの欠如
TypeScript の型定義って、実行時には消えるんですよね。userId: string と型を書いても、攻撃者は {"userId": {"$ne": null}} みたいな不正なペイロードを平気で送ってきます。だからこそ、Zod を使ったランタイムバリデーションが必須になるわけです。
// app/actions/update-profile.ts
"use server"
import { z } from "zod"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
const updateProfileSchema = z.object({
displayName: z
.string()
.min(1, "表示名は必須です")
.max(50, "表示名は50文字以内にしてください"),
bio: z
.string()
.max(200, "自己紹介は200文字以内にしてください")
.optional(),
website: z
.string()
.url("有効なURLを入力してください")
.optional()
.or(z.literal("")),
})
export async function updateProfile(formData: FormData) {
// 1. バリデーション
const rawData = {
displayName: formData.get("displayName"),
bio: formData.get("bio"),
website: formData.get("website"),
}
const result = updateProfileSchema.safeParse(rawData)
if (!result.success) {
return { error: result.error.flatten().fieldErrors }
}
// 2. 認証チェック
const session = await auth()
if (!session?.user) {
return { error: "認証が必要です" }
}
// 3. データ更新
await db.user.update({
where: { id: session.user.id },
data: result.data,
})
return { success: true }
}
大事なのは、バリデーション → 認証 → 処理という順序を徹底すること。クライアント側のバリデーションは UX 向上のためだけであって、セキュリティの保護にはまったくなりません。ここ、意外と見落としがちなポイントです。
2. 認証・認可チェックの漏れ
「認証済みのページから呼ばれるんだから大丈夫でしょ」——この考え方、危険です。
Server Actions は独立したエンドポイントなので、ページの認証チェックを完全にバイパスして直接呼び出せます。つまり、ページ側でどれだけガチガチに認証していても、アクション自体にチェックがなければ無意味なんです。
// app/actions/delete-post.ts
"use server"
import { auth } from "@/lib/auth"
import { db } from "@/lib/db"
export async function deletePost(postId: string) {
// 認証チェック
const session = await auth()
if (!session?.user) {
throw new Error("認証が必要です")
}
// 認可チェック(所有者確認)
const post = await db.post.findUnique({
where: { id: postId },
select: { authorId: true },
})
if (!post) {
throw new Error("記事が見つかりません")
}
if (post.authorId !== session.user.id) {
throw new Error("この操作を行う権限がありません")
}
await db.post.delete({ where: { id: postId } })
return { success: true }
}
認証(誰であるか)と認可(何ができるか)は別の概念です。この2つのチェックを、保護したいすべてのアクションに漏れなく実装しましょう。面倒に感じるかもしれませんが、ここをサボると本当に痛い目を見ます。
3. クロージャによる機密データの漏洩
これはちょっとハマりやすい罠です。コンポーネント内で Server Actions を定義すると、周囲のスコープの変数を自動的にキャプチャできてしまいます。Next.js はクロージャ変数を暗号化してクライアントに送信しますが、万が一暗号化が破られたらシークレットが丸見えになるリスクがあります。
// ❌ 危険:クロージャで機密データをキャプチャ
export default async function Page() {
const secretKey = process.env.STRIPE_SECRET_KEY
async function processPayment() {
"use server"
// secretKey がクロージャ経由でクライアントに送信される
await stripe(secretKey).charges.create({ ... })
}
return
}
// ✅ 安全:Server Actions を別ファイルに分離
// app/actions/payment.ts
"use server"
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function processPayment(formData: FormData) {
// 環境変数はサーバー上でのみアクセスされる
await stripe.charges.create({ ... })
}
対策はシンプル。Server Actions は常に専用のファイルに分離して、機密データをクロージャで絶対にキャプチャしないようにすること。これだけでリスクを大幅に減らせます。
4. レート制限の未実装
Server Actions にはデフォルトのレート制限がありません。これ、地味にやばいです。計算コストの高い処理や外部 API 呼び出しを含むアクションは、悪意のあるユーザーに簡単に悪用される可能性があります。
Upstash Redis を使ったレート制限の実装を見てみましょう。
// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
const redis = Redis.fromEnv()
export const rateLimiter = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(5, "60 s"),
prefix: "@app/ratelimit",
})
// app/actions/ai-generate.ts
"use server"
import { auth } from "@/lib/auth"
import { rateLimiter } from "@/lib/rate-limit"
import { headers } from "next/headers"
export async function generateContent(prompt: string) {
const session = await auth()
if (!session?.user) {
return { error: "認証が必要です" }
}
// ユーザー ID ベースのレート制限
const { success } = await rateLimiter.limit(
`ai-generate:${session.user.id}`
)
if (!success) {
return {
error: "リクエストが多すぎます。しばらく待ってから再試行してください。",
}
}
// AI API 呼び出し(コストのかかる処理)
const result = await callAIAPI(prompt)
return { data: result }
}
認証済みのアクションでは、IP アドレスベースよりもユーザー ID ベースのレート制限のほうが正確です。未認証のエンドポイントには IP ベースの制限を使いましょう。
5. エラー情報の過剰な露出
Server Actions でスローされたエラーは、最終的にクライアント側のコードに再スローされます。スタックトレースやデータベースのエラーメッセージが本番環境でそのまま表示されると、攻撃者にシステムの内部構造を教えてしまうことになります。
// ❌ 危険:内部エラーをそのまま返す
export async function createOrder(data: OrderInput) {
"use server"
try {
await db.order.create({ data })
} catch (error) {
throw error // DB のエラーメッセージがクライアントに漏洩
}
}
// ✅ 安全:汎用メッセージを返し、詳細はサーバーログに記録
export async function createOrder(data: OrderInput) {
"use server"
try {
await db.order.create({ data })
return { success: true }
} catch (error) {
console.error("注文作成エラー:", error) // サーバーログに記録
return { error: "注文の処理中にエラーが発生しました" }
}
}
本番環境では、ユーザーには汎用的なメッセージだけを返して、詳細はサーバーのログに記録する。これが鉄則です。
next-safe-action で一貫したセキュリティを実現する
ここまで読んで「全部のアクションにこれ書くの、けっこう大変じゃない?」と思った方、正解です。正直、手動でやるとコードの重複が多くなるし、どこかで漏れが出ます。
そこで登場するのが next-safe-action。バリデーション・認証・エラーハンドリングをミドルウェアパイプラインとして統一的に管理できるライブラリです。
アクションクライアントの作成
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action"
import { auth } from "@/lib/auth"
// 公開アクション用クライアント
export const publicActionClient = createSafeActionClient({
handleServerError: (error) => {
console.error("Server Action エラー:", error)
return "予期しないエラーが発生しました"
},
})
// 認証済みアクション用クライアント
export const authActionClient = createSafeActionClient({
handleServerError: (error) => {
console.error("Server Action エラー:", error)
return "予期しないエラーが発生しました"
},
}).use(async ({ next }) => {
const session = await auth()
if (!session?.user) {
throw new Error("認証が必要です")
}
return next({ ctx: { user: session.user } })
})
型安全なアクションの定義
// app/actions/update-settings.ts
"use server"
import { z } from "zod"
import { authActionClient } from "@/lib/safe-action"
import { db } from "@/lib/db"
const settingsSchema = z.object({
theme: z.enum(["light", "dark", "system"]),
language: z.string().min(2).max(5),
notifications: z.boolean(),
})
export const updateSettings = authActionClient
.schema(settingsSchema)
.action(async ({ parsedInput, ctx }) => {
await db.userSettings.upsert({
where: { userId: ctx.user.id },
update: parsedInput,
create: { userId: ctx.user.id, ...parsedInput },
})
return { message: "設定を更新しました" }
})
このパターンだと、バリデーションと認証は自動的に処理されるので、個々のアクションではビジネスロジックだけに集中できます。コードもスッキリするし、セキュリティの漏れも防げる。一石二鳥ですね。
ちなみに next-safe-action は Zod だけでなく、Valibot や ArkType など Standard Schema 準拠のバリデーションライブラリにも対応しています。
本番環境向けセキュリティチェックリスト
Server Actions を本番にデプロイする前に、以下の項目をひとつずつ確認してください。チェックリストとして使えるようにまとめました。
- Next.js と React のバージョン確認:CVE-2025-55182 の修正を含むバージョンにアップデート済みか
- 入力バリデーション:すべての Server Actions で Zod によるランタイムバリデーションを実装しているか
- 認証チェック:保護が必要なすべてのアクションでセッション検証を行っているか
- 認可チェック:リソースの所有者や権限をちゃんと確認しているか
- レート制限:コストの高いアクション(AI API、メール送信など)にレート制限を設けているか
- エラーハンドリング:内部エラーの詳細がクライアントに漏れていないか
- クロージャの安全性:Server Actions が機密データをクロージャでキャプチャしていないか
- シークレットのローテーション:脆弱性の修正後にシークレットをローテーションしたか
- CSP ヘッダー:Content Security Policy を適切に設定しているか
- 暗号化キーの管理:
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY環境変数を設定し、ビルド間で一貫させているか
よくある質問(FAQ)
Server Actions は API Route の代替になりますか?
多くのケースで代替になります。フォーム送信やデータ更新といった変更操作には Server Actions がぴったりです。ただし、Webhook の受信やサードパーティ連携など、外部から直接呼ばれる必要があるエンドポイントには、引き続き API Route(Route Handlers)を使いましょう。大事なのは、どちらを使う場合でも同じレベルのセキュリティ対策が必要だということです。
CVE-2025-55182 に自分のアプリが影響を受けるか確認するには?
package.json の next と react のバージョンをチェックしてください。Next.js 15.0.0〜16.0.6、React 19.0.0 を使っていたらアウトです。npx fix-react2shell-next を実行すれば、対話式でバージョン確認とアップグレードができます。App Router を使っていない場合や、React Server Components を使っていない場合は影響を受けません。
レート制限は Middleware と Server Actions のどちらに実装すべき?
理想を言えば、両方です。Middleware でのグローバルなレート制限は、明らかに不正なリクエストを早い段階で弾いてくれます。一方、Server Actions 内でのレート制限は、アクションごとに細かい制御(AI 生成は1分5回、フォーム送信は1分10回、など)が可能です。防御は多層化する——いわゆる Defense in Depth の考え方ですね。一箇所だけに頼らない設計を心がけましょう。
クライアント側のバリデーションがあればサーバー側は不要?
いいえ、絶対に省略しないでください。クライアント側のバリデーションはブラウザの DevTools で一瞬で無効化できますし、そもそも Server Actions は curl や自作スクリプトから直接叩けます。クライアント側のチェックなんて丸ごとスキップされるんです。クライアント側は UX のため、サーバー側はセキュリティのため。それぞれ別の目的で実装するものだと割り切ってください。
next-safe-action を使うべき?それとも自前で実装?
プロジェクトの規模次第です。アクションが数個しかない小規模プロジェクトなら、自前の実装でも全然いけます。でも、アクション数が増えてくると話は別。バリデーション・認証・エラーハンドリングの一貫性を手動で保つのは、正直かなりしんどいです。next-safe-action はミドルウェアパイプラインで共通処理をまとめられるので、中〜大規模のプロジェクトなら導入をおすすめします。