Next.jsにおけるデータセキュリティについての考え方
React Server Components はパフォーマンスを向上させ、データ取得を簡素化しますが、データのアクセス場所や方法も変更され、従来のフロントエンドアプリでのデータ処理におけるセキュリティの前提条件も一部変更されます。
このガイドでは、Next.jsでのデータセキュリティについての考え方と、ベストプラクティスの実装方法を説明します。
データ取得のアプローチ
プロジェクトの規模や年数に応じて、推奨されるデータ取得アプローチは主に3つあります。
- HTTP API:既存の大規模アプリケーションや組織向け。
- データアクセスレイヤー:新規プロジェクト向け。
- コンポーネントレベルのデータアクセス:プロトタイプや学習用。
データ取得アプローチは1つに絞り、混在させないことをお勧めします。これにより、コードベースで作業する開発者とセキュリティ監査担当者の両方にとって、期待されることが明確になります。
外部HTTP API
既存のプロジェクトでServer Componentsを採用する際は、ゼロトラストモデルに従ってください。Server Componentsから、Client Componentsで行うのと同様に、fetchを使用してRESTやGraphQLなどの既存のAPIエンドポイントを呼び出し続けることができます。
import { cookies } from 'next/headers'
export default async function Page() {
const cookieStore = cookies()
const token = cookieStore.get('AUTH_TOKEN')?.value
const res = await fetch('https://api.example.com/profile', {
headers: {
Cookie: `AUTH_TOKEN=${token}`,
// Other headers
},
})
// ....
}このアプローチが有効なのは、
- 既にセキュリティ対策が実施されている場合。
- バックエンドチームが他の言語を使用したり、APIを独立して管理している場合。
データアクセスレイヤー
新規プロジェクトでは、専用のデータアクセスレイヤー(DAL)の作成を推奨します。これは、データの取得方法やタイミング、そしてレンダリングコンテキストに渡されるものを制御する内部ライブラリです。
データアクセスレイヤーは、
- サーバーでのみ実行する。
- 認可チェックを実行する。
- 安全で最小限のデータ転送オブジェクト(DTO)を返す。
このアプローチは、すべてのデータアクセスロジックを一元化し、一貫したデータアクセスを強制しやすくし、認可バグのリスクを軽減します。また、リクエストの異なる部分間でインメモリキャッシュを共有できるという利点もあります。
import { cache } from 'react'
import { cookies } from 'next/headers'
// Cached helper methods makes it easy to get the same value in many places
// without manually passing it around. This discourages passing it from Server
// Component to Server Component which minimizes risk of passing it to a Client
// Component.
export const getCurrentUser = cache(async () => {
const token = cookies().get('AUTH_TOKEN')
const decodedToken = await decryptAndValidate(token)
// Don't include secret tokens or private information as public fields.
// Use classes to avoid accidentally passing the whole object to the client.
return new User(decodedToken.id)
})import 'server-only'
import { getCurrentUser } from './auth'
function canSeeUsername(viewer: User) {
// Public info for now, but can change
return true
}
function canSeePhoneNumber(viewer: User, team: string) {
// Privacy rules
return viewer.isAdmin || team === viewer.team
}
export async function getProfileDTO(slug: string) {
// Don't pass values, read back cached values, also solves context and easier to make it lazy
// use a database API that supports safe templating of queries
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
const currentUser = await getCurrentUser()
// only return the data relevant for this query and not everything
// <https://www.w3.org/2001/tag/doc/APIMinimization>
return {
username: canSeeUsername(currentUser) ? userData.username : null,
phonenumber: canSeePhoneNumber(currentUser, userData.team)
? userData.phonenumber
: null,
}
}import { getProfile } from '../../data/user'
export async function Page({ params: { slug } }) {
// This page can now safely pass around this profile knowing
// that it shouldn't contain anything sensitive.
const profile = await getProfile(slug);
...
}知っておくと良いこと:シークレットキーは環境変数に保存する必要がありますが、
process.envにアクセスできるのはデータアクセスレイヤーのみです。これにより、シークレットがアプリケーションの他の部分に公開されるのを防ぎます。
コンポーネントレベルのデータアクセス
迅速なプロトタイプ作成やイテレーションのためには、データベースクエリをServer Componentsに直接配置できます。
しかし、このアプローチでは、例えば、プライベートデータが誤ってクライアントに公開されやすくなります。
import Profile from './components/profile.tsx'
export async function Page({ params: { slug } }) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
// EXPOSED: This exposes all the fields in userData to the client because
// we are passing the data from the Server Component to the Client.
return <Profile user={userData} />
}'use client'
// BAD: This is a bad props interface because it accepts way more data than the
// Client Component needs and it encourages server components to pass all that
// data down. A better solution would be to accept a limited object with just
// the fields necessary for rendering the profile.
export default async function Profile({ user }: { user: User }) {
return (
<div>
<h1>{user.name}</h1>
...
</div>
)
}Client Componentに渡す前に、データをサニタイズする必要があります。
import { sql } from './db'
export async function getUser(slug: string) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const user = rows[0]
// Return only the public fields
return {
name: user.name,
}
}import { getUser } from '../data/user'
import Profile from './ui/profile'
export default async function Page({
params: { slug },
}: {
params: { slug: string }
}) {
const publicProfile = await getUser(slug)
return <Profile user={publicProfile} />
}データの読み取り
サーバーからクライアントへのデータ渡し
初期ロード時、Server ComponentsとClient Componentsは両方ともサーバー上でHTMLを生成するために実行されます。しかし、それらは独立したモジュールシステムで実行されます。これにより、Server ComponentsはプライベートデータやAPIにアクセスできる一方、Client Componentsはアクセスできないことが保証されます。
サーバーコンポーネント
- サーバーでのみ実行する。
- 環境変数、シークレット、データベース、内部APIに安全にアクセスできる。
クライアントコンポーネント
- プリレンダリング中にサーバーで実行されますが、ブラウザで実行されるコードと同じセキュリティ前提条件に従う必要があります。
- 特権データやサーバー専用モジュールにアクセスしてはいけません。
これにより、アプリケーションはデフォルトで安全になりますが、データの取得方法やコンポーネントへの渡し方によって、誤ってプライベートデータが公開される可能性があります。
汚染(Tainting)
プライベートデータがクライアントに誤って公開されるのを防ぐために、React Taint APIを使用できます。
- データオブジェクトには
experimental_taintObjectReference。 - 特定の値には
experimental_taintUniqueValue。
next.config.js の experimental.taint オプションで、Next.jsアプリでの使用を有効にできます。
module.exports = {
experimental: {
taint: true,
},
}これにより、汚染されたオブジェクトや値がクライアントに渡されるのを防ぎます。ただし、これは追加の保護層であり、DALでReactのレンダリングコンテキストに渡す前に、データをフィルタリングおよびサニタイズする必要があります。
知っておくと良いこと
- デフォルトでは、環境変数はサーバーでのみ利用可能です。Next.jsは、
NEXT_PUBLIC_で始まる環境変数をクライアントに公開します。詳細はこちら。- 関数とクラスは、デフォルトでClient Componentsに渡されるのがブロックされています。
サーバー専用コードのクライアントサイド実行の防止
サーバー専用コードがクライアントで実行されるのを防ぐには、モジュールをserver-onlyパッケージでマークできます。
pnpm add server-onlyimport 'server-only'
//...これにより、組み込みコードや内部ビジネスロジックがサーバーにとどまることが保証され、クライアント環境でモジュールがインポートされた場合にビルドエラーが発生します。
データの変更(Mutating Data)
Next.jsでは、Server Actions で変更を処理します。
組み込みサーバーアクションのセキュリティ機能
デフォルトでは、Server Actionが作成およびエクスポートされると、公開HTTPエンドポイントが作成され、同じセキュリティ前提条件と認可チェックで処理される必要があります。これは、Server Actionまたはユーティリティ関数がコードの他の場所でインポートされていなくても、公開からアクセス可能であることを意味します。
セキュリティを向上させるために、Next.jsには以下の組み込み機能があります。
- 安全なアクションID:Next.jsは、クライアントがServer Actionを参照して呼び出すことを許可するために、暗号化された非決定的なIDを作成します。これらのIDは、セキュリティを強化するためにビルド間で定期的に再計算されます。
- デッドコード削除:未使用のServer Action(IDによって参照される)は、公開アクセスを避けるためにクライアントバンドルから削除されます。
知っておくと良いこと:
IDはコンパイル中に作成され、最大14日間キャッシュされます。新しいビルドが開始されたとき、またはビルドキャッシュが無効になったときに再生成されます。このセキュリティ改善により、認証レイヤーが欠落している場合の危険性が軽減されます。ただし、Server Actionは引き続き公開HTTPエンドポイントとして扱うべきです。
// app/actions.js
'use server'
// If this action **is** used in our application, Next.js
// will create a secure ID to allow the client to reference
// and call the Server Action.
export async function updateUserAction(formData) {}
// If this action **is not** used in our application, Next.js
// will automatically remove this code during `next build`
// and will not create a public endpoint.
export async function deleteUserAction(formData) {}クライアント入力の検証
クライアントからの入力(フォームデータ、URLパラメータ、ヘッダー、searchParamsなど)は簡単に変更できるため、常に検証する必要があります。
// BAD: Trusting searchParams directly
export default async function Page({ searchParams }) {
const isAdmin = searchParams.get('isAdmin')
if (isAdmin === 'true') {
// Vulnerable: relies on untrusted client data
return <AdminPanel />
}
}
// GOOD: Re-verify every time
import { cookies } from 'next/headers'
import { verifyAdmin } from './auth'
export default async function Page() {
const token = cookies().get('AUTH_TOKEN')
const isAdmin = await verifyAdmin(token)
if (isAdmin) {
return <AdminPanel />
}
}認証と認可
ユーザーがアクションを実行する権限があることを常に確認する必要があります。例:
'use server'
import { auth } from './lib'
export function addItem() {
const { user } = auth()
if (!user) {
throw new Error('You must be signed in to perform this action')
}
// ...
}Next.jsでの認証について詳しくはこちら。
クロージャと暗号化
コンポーネント内でServer Actionを定義すると、アクションが外側の関数のスコープにアクセスできるクロージャが作成されます。例えば、publishアクションはpublishVersion変数にアクセスできます。
export default async function Page() {
const publishVersion = await getLatestVersion();
async function publish() {
"use server";
if (publishVersion !== await getLatestVersion()) {
throw new Error('The version has changed since pressing publish');
}
...
}
return (
<form>
<button formAction={publish}>Publish</button>
</form>
);
}クロージャは、レンダリング時にデータのスナップショット(例:publishVersion)をキャプチャし、アクションが呼び出されたときに使用できるようにする場合に役立ちます。
ただし、これが発生するために、キャプチャされた変数はクライアントに送信され、アクションが呼び出されたときにサーバーに戻されます。機密データがクライアントに公開されるのを防ぐために、Next.jsはクロージャ化された変数を自動的に暗号化します。新しいプライベートキーは、Next.jsアプリケーションがビルドされるたびに各アクションに対して生成されます。これは、アクションが特定のビルドに対してのみ呼び出せることを意味します。
知っておくと良いこと:機密値がクライアントに公開されるのを防ぐために、暗号化だけに依存することは推奨しません。
暗号化キーの上書き(高度)
Next.jsアプリケーションを複数のサーバーにセルフホストする場合、各サーバーインスタンスは異なる暗号化キーを持つ可能性があり、潜在的な不整合につながる可能性があります。
これを軽減するために、process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY環境変数を使用して暗号化キーを上書きできます。この変数を指定すると、暗号化キーがビルド間で永続化され、すべてのサーバーインスタンスが同じキーを使用するようになります。この変数はAES-GCMで暗号化されている必要があります。
これは、複数のデプロイメントで一貫した暗号化動作がアプリケーションにとって重要となる高度なユースケースです。キーローテーションや署名などの標準的なセキュリティプラクティスを検討する必要があります。
知っておくと良いこと:VercelにデプロイされたNext.jsアプリケーションは、これを自動的に処理します。
許可されたオリジン(高度)
Server Actionsは<form>要素で呼び出すことができるため、CSRF攻撃の対象となります。
バックグラウンドでは、Server ActionsはPOSTメソッドを使用し、このHTTPメソッドのみがそれらを呼び出すことができます。これにより、最新のブラウザでのほとんどのCSRF脆弱性が防止されます。特にSameSite Cookiesはデフォルトです。
追加の保護として、Next.jsのServer ActionsはOriginヘッダーとHostヘッダー(またはX-Forwarded-Host)を比較します。これらが一致しない場合、リクエストは中止されます。つまり、Server Actionsは、それらをホストしているページのホストと同じホストでしか呼び出せません。
リバースプロキシや多層バックエンドアーキテクチャ(サーバーAPIが本番ドメインと異なる場合)を使用する大規模なアプリケーションでは、serverActions.allowedOrigins設定オプションを使用して安全なオリジンのリストを指定することをお勧めします。このオプションは文字列の配列を受け取ります。
/** @type {import('next').NextConfig} */
module.exports = {
experimental: {
serverActions: {
allowedOrigins: ['my-proxy.com', '*.my-proxy.com'],
},
},
}このガイドで言及されているトピックについて、セキュリティとServer Actionsについて詳しくはこちら。
レンダリング中の副作用の回避
ミューテーション(例:ログアウト、データベースの更新、キャッシュの無効化)は、Server ComponentsでもClient Componentsでも、副作用であってはなりません。Next.jsは、意図しない副作用を避けるために、レンダリングメソッド内でのCookieの設定やキャッシュ再検証のトリガーを明示的に禁止しています。
// BAD: Triggering a mutation during rendering
export default async function Page({ searchParams }) {
if (searchParams.get('logout')) {
cookies().delete('AUTH_TOKEN')
}
return <UserProfile />
}代わりに、ミューテーションを処理するためにServer Actionsを使用する必要があります。
// GOOD: Using Server Actions to handle mutations
import { logout } from './actions'
export default function Page() {
return (
<>
<UserProfile />
<form action={logout}>
<button type="submit">Logout</button>
</form>
</>
)
}知っておくと良いこと:Next.jsは、ミューテーションを処理するために
POSTリクエストを使用します。これにより、GETリクエストからの意図しない副作用を防ぎ、クロスサイトリクエストフォージェリ(CSRF)のリスクを軽減します。
監査
Next.jsプロジェクトの監査を行う場合、特に注意すべき点はいくつかあります。
- データアクセスレイヤー:分離されたデータアクセスレイヤーの確立されたプラクティスはありますか?データベースパッケージと環境変数がデータアクセスレイヤーの外にインポートされていないことを確認してください。
"use client"ファイル:Componentのpropsはプライベートデータを期待していますか?型シグネチャは広すぎませんか?"use server"ファイル:Actionの引数は、Action内またはデータアクセスレイヤー内で検証されますか?ユーザーはAction内で再承認されますか?/[param]/.括弧で囲まれたフォルダーはユーザー入力です。paramは検証されますか?proxy.tsおよびroute.ts:非常に強力です。従来の技術を使用して、これらの監査にさらに時間をかけてください。ペネトレーションテストや脆弱性スキャンを定期的に、またはチームのソフトウェア開発ライフサイクルに合わせて実施してください。
役に立ちましたか?