Next.jsにおけるデータセキュリティの考え方
React Server Componentsはパフォーマンスを向上させ、データ取得を簡素化しますが、データにアクセスする場所と方法も変わり、フロントエンドアプリケーションでデータを扱う従来のセキュリティの前提条件が変わります。
このガイドは、Next.jsでデータセキュリティをどのように考えるべきか、またベストプラクティスをどのように実装するかを理解するのに役立ちます。
データ取得の方法
プロジェクトのサイズと経過年数に応じて、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}`,
// その他のヘッダー
},
})
// ....
}このアプローチは、以下の場合に効果的です。
- すでにセキュリティプラクティスが整っている
- 独立したバックエンドチームが他の言語を使用しているか、APIを独立して管理している
データアクセスレイヤー
新規プロジェクトでは、専用の**データアクセスレイヤー(DAL)**を作成することをお勧めします。これは、データをいつどのように取得するか、また何をレンダーコンテキストに渡すかを制御する内部ライブラリです。
データアクセスレイヤーは、以下を満たす必要があります。
- サーバー上でのみ実行される
- 認可チェックを実行する
- 安全で最小限の**データ転送オブジェクト(DTO)**を返す
このアプローチはすべてのデータアクセスロジックを一元化するため、一貫したデータアクセスを強制しやすくなり、認可バグのリスクが低減します。また、リクエストの異なる部分間でインメモリキャッシュを共有できるという利点もあります。
import { cache } from 'react'
import { cookies } from 'next/headers'
// キャッシュされたヘルパーメソッドにより、多くの場所で同じ値を簡単に取得できます。
// これにより、Server ComponentからServer Componentへ手動で渡すことが抑止され、
// Client Componentに渡されるリスクが最小化されます。
export const getCurrentUser = cache(async () => {
const token = cookies().get('AUTH_TOKEN')
const decodedToken = await decryptAndValidate(token)
// シークレットトークンやプライベート情報をパブリックフィールドに含めないでください。
// クラスを使用して、オブジェクト全体をクライアントに誤って渡さないようにしてください。
return new User(decodedToken.id)
})import 'server-only'
import { getCurrentUser } from './auth'
function canSeeUsername(viewer: User) {
// 今はパブリック情報ですが、変更される可能性があります
return true
}
function canSeePhoneNumber(viewer: User, team: string) {
// プライバシールール
return viewer.isAdmin || team === viewer.team
}
export async function getProfileDTO(slug: string) {
// 値を渡さず、キャッシュされた値を読み直します。これはコンテキストも解決し、より簡単に遅延実行できます
// 安全なクエリテンプレートをサポートするデータベースAPIを使用してください
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`
const userData = rows[0]
const currentUser = await getCurrentUser()
// このクエリに関連するデータのみを返し、すべてを返さないでください
// <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 } }) {
// このページは安全にこのプロフィールを渡すことができるようになります。
// なぜなら、機密情報を含むべきではないことが分かっているからです。
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]
// 露出:userData内のすべてのフィールドが、Server ComponentからClient Componentに
// データを渡しているため、クライアントに露出します。
return <Profile user={userData} />
}'use client'
// 悪い例:このPropsインターフェースは、Client Componentが必要とする
// データよりもはるかに多くのデータを受け取るため、悪い例です。
// また、Server Componentsがそのすべてのデータを下に渡すことを促しています。
// より良いソリューションは、プロフィールのレンダリングに必要な
// フィールドのみを含む制限されたオブジェクトを受け取ることです。
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 {
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はアクセスできません。
Server Components:
- サーバー上でのみ実行されます。
- 環境変数、シークレット、データベース、内部APIに安全にアクセスできます。
Client Components:
- プリレンダリング中はサーバー上で実行されますが、ブラウザで実行されるコードと同じセキュリティ前提条件に従う必要があります。
- 特権データやサーバーのみのモジュールにアクセスしてはいけません。
これにより、アプリケーションはデフォルトで安全になりますが、データの取得方法またはコンポーネントへのデータ送信方法によって、プライベートデータを誤って露出させることが可能です。
Tainting
プライベートデータがクライアントに誤って露出するのを防ぐために、React Taint APIを使用できます。
experimental_taintObjectReference:データオブジェクト用experimental_taintUniqueValue:特定の値用
Next.jsアプリで、next.config.jsのexperimental.taintオプションで使用を有効にできます。
module.exports = {
experimental: {
taint: true,
},
}これにより、taintされたオブジェクトまたは値がクライアントに渡されるのが防止されます。ただし、これは追加の保護層に過ぎません。Reactのレンダーコンテキストに渡す前に、DALでデータをフィルタリングおよびサニタイズする必要があります。
補足:
- デフォルトでは、環境変数はサーバーでのみ利用可能です。Next.jsは
NEXT_PUBLIC_で始まる環境変数をクライアントに公開します。詳しく学ぶ- 関数とクラスは、デフォルトによってClient Componentsに渡されるのがすでにブロックされています。
サーバーのみのコードのクライアント側実行を防止する
サーバーのみのコードがクライアント上で実行されるのを防ぐために、server-onlyパッケージでモジュールをマークできます。
npm install server-onlyyarn add server-onlypnpm add server-onlybun add server-onlyimport 'server-only'
//...これにより、モジュールがクライアント環境にインポートされた場合、ビルドエラーが発生することで、専有コードまたは内部ビジネスロジックがサーバーにとどまることを保証します。
データの変更
Next.jsはServer Actionsでミューテーションを処理します。
Server Actionsのビルトイン セキュリティ機能
デフォルトでは、Server Actionが作成されてエクスポートされると、パブリックHTTPエンドポイントが作成され、同じセキュリティ前提条件と認可チェックで扱う必要があります。つまり、Server Actionまたはユーティリティ関数がコード内の他の場所にインポートされていなくても、やはりパブリックにアクセス可能です。
セキュリティを向上させるため、Next.jsは以下のビルトイン機能を備えています。
- セキュアなアクション ID: Next.jsは暗号化された非決定論的なIDを作成して、クライアントがServer Actionを参照および呼び出せるようにします。これらのIDは、セキュリティの強化のため、ビルド間で定期的に再計算されます。
- デッドコード除去: 未使用のServer Actions(IDによって参照される)はクライアントバンドルから削除され、パブリックアクセスが防止されます。
補足:
IDはコンパイル中に作成され、最大14日間キャッシュされます。新しいビルドが開始されるか、ビルドキャッシュが無効化されると、再生成されます。 このセキュリティ向上により、認証レイヤーが不足している場合のリスクが低減されます。ただし、Server Actionsをパブリック HTTPエンドポイントのように扱う必要があります。
// app/actions.js
'use server'
// このアクションがアプリケーションで**使用されている**場合、Next.jsは
// クライアントがServer Actionを参照して呼び出すことができるよう、
// セキュアなIDを作成します。
export async function updateUserAction(formData) {}
// このアクションがアプリケーションで**使用されていない**場合、Next.jsは
// `next build`時に自動的にこのコードを削除し、
// パブリックエンドポイントを作成しません。
export async function deleteUserAction(formData) {}クライアント入力の検証
クライアント入力は簡単に変更できるため、常に検証する必要があります。例えば、フォームデータ、URLパラメーター、ヘッダー、searchParamsなどです。
// 悪い例:searchParamsを直接信頼する
export default async function Page({ searchParams }) {
const isAdmin = searchParams.get('isAdmin')
if (isAdmin === 'true') {
// 脆弱性:信頼できないクライアントデータに依存している
return <AdminPanel />
}
}
// 良い例:毎回再検証する
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('このアクションを実行するにはサインインしている必要があります')
}
// ...
}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('発行を押してからバージョンが変更されています');
}
...
}
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 headerとHost header(または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は、意図しない副作用を回避するため、レンダーメソッド内でのクッキー設定またはキャッシュ無効化のトリガーを明示的に防止しています。
// 悪い例:レンダリング中にミューテーションをトリガーする
export default async function Page({ searchParams }) {
if (searchParams.get('logout')) {
cookies().delete('AUTH_TOKEN')
}
return <UserProfile />
}代わりに、ミューテーションを処理するためにServer Actionsを使用する必要があります。
// 良い例:ミューテーションを処理するためにServer Actionsを使用する
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"ファイル: コンポーネントのPropsがプライベートデータを期待していますか?型シグネチャは過度に広くありませんか?"use server"ファイル: アクション引数がアクション内またはデータアクセスレイヤー内で検証されていますか?ユーザーはアクション内で再認可されていますか?/[param]/.ブラケット付きのフォルダはユーザー入力です。paramsは検証されていますか?proxy.tsとroute.ts: 多くの力を持っています。従来の手法を使用して、これらに余分な時間をかけて監査します。定期的に、またはチームのソフトウェア開発ライフサイクルに合わせてペネトレーションテストまたは脆弱性スキャンを実行します。