コンテンツにスキップ
ブログに戻る

2023年10月23日月曜日

Next.js におけるセキュリティの考え方

投稿者

App Router の React Server Components (RSC) は、従来の方式に伴う多くの冗長性や潜在的なリスクを排除する新しいパラダイムです。新しいため、開発者やそれに続くセキュリティチームは、既存のセキュリティプロトコルをこのモデルに適合させるのに苦労するかもしれません。

このドキュメントは、注意すべきいくつかの領域、組み込まれている保護機能、およびアプリケーションの監査ガイドを含めて説明することを目的としています。特に、偶発的なデータ漏洩のリスクに焦点を当てます。

データ処理モデルの選択

React Server Components は、サーバーとクライアントの境界線を曖昧にします。データ処理は、情報がどこで処理され、 subsequently 利用可能になるかを理解する上で非常に重要です。

まず、プロジェクトに適したデータ処理アプローチを選択する必要があります。

あまり混在させず、1つのアプローチに固執することを推奨します。これにより、コードベースで作業する開発者とセキュリティ監査者の両方にとって、期待されることが明確になります。例外は疑わしいものとして際立ちます。

HTTP API

既存のプロジェクトに Server Components を導入する場合、推奨されるアプローチは、Server Components を実行時にデフォルトで安全でない/信頼できないものとして扱うか、SSR 内またはクライアント内で扱うことです。したがって、内部ネットワークや信頼ゾーンの想定はありません。エンジニアは Zero Trust の概念を適用できます。代わりに、fetch() を使用して Server Components から REST や GraphQL のようなカスタム API エンドポイントのみを呼び出します。これはクライアントから実行する場合と同じように動作します。Cookie も渡されます。

データベースに接続する既存の getStaticProps/getServerSideProps があった場合、モデルを統合してこれらも API エンドポイントに移動することを検討してください。そうすれば、物事を行うための単一の方法が得られます。

内部ネットワークからのフェッチが安全であると想定するアクセス制御に注意してください。

このアプローチにより、既存の組織構造を維持できます。ここでは、セキュリティを専門とする既存のバックエンドチームが、既存のセキュリティプラクティスを適用できます。これらのチームが JavaScript 以外の言語を使用している場合でも、このアプローチではうまく機能します。

クライアントに送信されるコードが少なく、データウォーターフォールが低遅延で実行されるという Server Components の多くの利点を依然として活用できます。

データアクセスレイヤー

新規プロジェクト向けの推奨アプローチは、JavaScript コードベース内に個別のデータアクセスレイヤーを作成し、すべてのデータアクセスをそこに統合することです。このアプローチは、一貫したデータアクセスを保証し、認可エラーが発生する可能性を低減します。単一のライブラリに統合するため、保守も容易になります。単一のプログラミング言語で、チームの連携を改善できる可能性があります。また、ランタイムオーバーヘッドが低く、リクエストのさまざまな部分間でインメモリキャッシュを共有できるなど、パフォーマンスの向上も得られます。

呼び出し元に渡す前に、カスタムデータアクセスチェックを提供する内部 JavaScript ライブラリを構築します。HTTP エンドポイントに似ていますが、同じメモリモデル内で行われます。すべての API は現在のユーザーを受け入れ、ユーザーがこのデータにアクセスできるかどうかを確認してから返します。原則として、Server Component の関数本体は、現在のリクエストを発行したユーザーがアクセスを許可されているデータのみを参照するべきです。

この時点から、API の実装に関する通常のセキュリティプラクティスが適用されます。

data/auth.tsx
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);
});
data/user-dto.tsx
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,
  };
}

これらのメソッドは、そのままクライアントに転送しても安全なオブジェクトを公開するべきです。これらを、クライアントが消費する準備ができていることを明確にするために、データ転送オブジェクト (DTO) と呼ぶのが好きです。

実際には Server Components によってのみ消費される可能性があります。これにより、セキュリティ監査が主にデータアクセスレイヤーに集中でき、UI は迅速にイテレーションできるレイヤーが作成されます。表面積が小さく、カバーするコードが少ないため、セキュリティの問題を検出しやすくなります。

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 に直接記述することです。このアプローチは、迅速なイテレーションとプロトタイピングにのみ適しています。たとえば、リスクとそれらに注意する方法を全員が理解している小規模なチームの小規模な製品の場合です。

このアプローチでは、"use client" ファイルを慎重に監査する必要があります。監査および PR のレビュー中に、エクスポートされたすべての関数、および型シグネチャが User のような過度に広範なオブジェクトを受け入れるか、tokencreditCard のようなプロパティを含んでいるかどうかを確認してください。phoneNumber のようなプライバシーに関わるフィールドでさえ、追加の精査が必要です。Client Component は、そのジョブを実行するために必要な最小限のデータよりも多くのデータを受け入れるべきではありません。

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.
  // This is similar to returning `userData` in `getServerSideProps`
  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>
  );
}

SQL インジェクション攻撃を回避するために、常にパラメータ化クエリ、またはそれを処理する DB ライブラリを使用してください。

サーバー専用

サーバーでのみ実行されるべきコードは、以下のようにマークできます。

import 'server-only';

これにより、Client Component がこのモジュールをインポートしようとすると、ビルドがエラーになります。これは、専有/機密性の高いコードや内部ビジネスロジックが誤ってクライアントに漏洩しないようにするために使用できます。

データを転送する主な方法は、React Server Components プロトコルを使用することです。これは、Client Components にプロップを渡すときに自動的に行われます。このシリアライゼーションは、JSON のスーパーセットをサポートします。カスタムクラスの転送はサポートされておらず、エラーが発生します。

したがって、大きすぎるオブジェクトが誤ってクライアントに公開されるのを防ぐための便利なトリックは、データアクセスレコードに class を使用することです。

Next.js 14 の今後のリリースでは、next.config.jstaint フラグを有効にすることで、実験的な React Taint API も試すことができます。

next.config.js
module.exports = {
  experimental: {
    taint: true,
  },
};

これにより、そのままクライアントに渡されるべきではないオブジェクトをマークできます。

app/data.ts
import { experimental_taintObjectReference } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Do not pass user data to the client',
    data
  );
  return data;
}
app/page.tsx
import { getUserData } from './data';
 
export async function Page({ searchParams }) {
  const userData = getUserData(searchParams.id);
  return <ClientComponent user={userData} />; // error
}

これは、このオブジェクトからデータフィールドを抽出して渡すことを防ぐものではありません。

app/page.tsx
export async function Page({ searchParams }) {
  const { name, phone } = getUserData(searchParams.id);
  // Intentionally exposing personal data
  return <ClientComponent name={name} phoneNumber={phone} />;
}

トークンのような一意の文字列については、taintUniqueValue を使用して、生の値をブロックすることもできます。

app/data.ts
import { experimental_taintObjectReference, experimental_taintUniqueValue } from 'react';
 
export async function getUserData(id) {
  const data = ...;
  experimental_taintObjectReference(
    'Do not pass user data to the client',
    data
  );
  experimental_taintUniqueValue(
    'Do not pass tokens to the client',
    data,
    data.token
  );
  return data;
}

ただし、これでも派生値はブロックされません。

データアクセスレイヤーを使用して、そもそも Server Components にデータが入らないようにするのが最善です。Taint チェックは、値を指定することで間違いに対する追加の保護層を提供します。関数とクラスは既に Client Components に渡されることがブロックされていることに注意してください。より多くのレイヤーが、何かをすり抜けるリスクを最小限に抑えます。

デフォルトでは、環境変数はサーバーでのみ利用可能です。慣例として、Next.js は NEXT_PUBLIC_ で始まる環境変数もクライアントに公開します。これにより、クライアントで利用可能であるべき特定の明示的な構成を公開できます。

SSR vs RSC

初回ロード時、Next.js は Server Components と Client Components の両方をサーバーで実行して HTML を生成します。

Server Components (RSC) は、2 つのモジュール間で情報が偶発的に公開されるのを避けるため、Client Components とは別のモジュールシステムで実行されます。

サーバーサイドレンダリング (SSR) を介してレンダリングされる Client Components は、ブラウザクライアントと同じセキュリティポリシーを持つと見なされるべきです。特権データやプライベート API へのアクセス権を取得するべきではありません。この保護を回避しようとするハック (グローバルオブジェクトにデータを隠すなど) の使用は強く推奨されません。このコードは、サーバーとクライアントで同じように実行できるという原則です。デフォルトで安全なプラクティスに沿って、Next.js は server-only モジュールが Client Component からインポートされた場合にビルドを失敗させます。

読み取り

Next.js App Router では、データベースや API からデータを読み取ることは、Server Component ページをレンダリングすることによって実装されます。

ページへの入力は、URL の searchParams、URL からマッピングされた動的パラメータ、およびヘッダーです。これらはクライアントによって異なる値として悪用される可能性があります。信頼すべきではなく、読み取るたびに再検証する必要があります。たとえば、?isAdmin=true のようなものを追跡するために searchParam を使用すべきではありません。ユーザーが /[team]/ にいるというだけで、そのチームにアクセスできるわけではありません。これは、データを読み取るときに検証する必要があります。原則として、データ読み取り時に常にアクセス制御と cookies() を再読み取りします。プロップやパラメータとして渡さないでください。

Server Component のレンダリングは、ミューテーションのような副作用を実行するべきではありません。これは Server Components に限ったことではありません。React は、useEffect 以外で Client Component をレンダリングする場合でも、二重レンダリングのようなことを行うことで、副作用を自然に discourages します。

さらに、Next.js では、レンダリング中に Cookie を設定したり、キャッシュの再検証をトリガーしたりする方法はありません。これも、レンダリングをミューテーションに使用することを discourages します。

たとえば、searchParams は、変更の保存やログアウトのような副作用を実行するために使用すべきではありません。これには Server Actions を使用する必要があります。

これは、Next.js モデルが意図されたとおりに使用される場合、副作用のために GET リクエストを使用しないことを意味します。これは、CSRF 問題の大きな原因を回避するのに役立ちます。

Next.js はカスタムルートハンドラー (route.tsx) をサポートしていますが、GET で Cookie を設定できます。これは一般的なモデルの一部ではなく、エスケープハッチと見なされます。これらのエンドポイントは、GET リクエストを受け入れるように明示的にオプトインする必要があります。誤って GET リクエストを受け取る可能性のあるキャッチオールハンドラーはありません。カスタム GET ハンドラーを作成することにした場合は、追加の監査が必要になる場合があります。

書き込み

Next.js App Router で書き込みやミューテーションを実行する標準的な方法は、Server Actions を使用することです。

actions.ts
'use server';
 
export function logout() {
  cookies().delete('AUTH_TOKEN');
}

"use server" アノテーションは、エクスポートされたすべての関数をクライアントから呼び出し可能にするエンドポイントを公開します。識別子は現在、ソースコードの場所のハッシュです。ユーザーがアクションの ID のハンドルを取得している限り、任意の引数でそれを呼び出すことができます。

その結果、これらの関数は常に、現在のユーザーがこのアクションを呼び出すことが許可されているかどうかを検証することから始めるべきです。関数は、各引数の整合性も検証する必要があります。これは手動で、または zod のようなツールで行うことができます。

actions.ts
"use server";
 
export async function deletePost(id: number) {
  if (typeof id !== 'number') {
    // The TypeScript annotations are not enforced so
    // we might need to check that the id is what we
    // think it is.
    throw new Error();
  }
  const user = await getCurrentUser();
  if (!canDeletePost(user, id)) {
    throw new Error();
  }
  ...
}

クロージャ

Server Actions はクロージャ にエンコードすることもできます。これにより、アクションはレンダリング時に使用されたデータのスナップショットに関連付けられるため、アクションが呼び出されたときにそれを使用できます。

app/page.tsx
export default 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 <button action={publish}>Publish</button>;
}
 

クロージャのスナップショットは、サーバーが呼び出されたときにクライアントに送信され、そこから戻される必要があります。

Next.js 14 では、閉じた変数はアクション ID で暗号化されてからクライアントに送信されます。デフォルトでは、Next.js プロジェクトのビルド中にプライベートキーが自動的に生成されます。再ビルドごとに新しいプライベートキーが生成されるため、各 Server Action は特定のビルドに対してのみ呼び出すことができます。Skew Protection を使用して、再デプロイ時に常に正しいバージョンを呼び出すことを保証することを推奨します。

より頻繁にローテーションするキー、または複数のビルドにわたって永続的なキーが必要な場合は、NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 環境変数を使用して手動で構成できます。

閉じたすべての変数を暗号化することで、それらに秘密情報が誤って公開されるのを防ぎます。署名することで、攻撃者がアクションへの入力を改ざんするのが難しくなります。

クロージャを使用する別の方法として、JavaScript の .bind(...) 関数を使用する方法があります。これらは暗号化されていません。 これはパフォーマンスのためのオプトアウトを提供し、クライアントでの .bind() とも整合しています。

app/page.tsx
async function deletePost(id: number) {
  "use server";
  // verify id and that you can still delete it
  ...
}
 
export async function Page({ slug }) {
  const post = await getPost(slug);
  return <button action={deletePost.bind(null, post.id)}>
    Delete
  </button>;
}

原則として、Server Actions ("use server") の引数リストは常に悪意のあるものとして扱われ、入力は検証される必要があります。

CSRF

すべての Server Actions は、プレーンな <form> から呼び出すことができます。これにより、CSRF 攻撃に対して脆弱になる可能性があります。裏側では、Server Actions は常に POST を使用して実装されており、この HTTP メソッドのみがそれらを呼び出すことができます。これだけでも、現代のブラウザではほとんどの CSRF 脆弱性を防ぐことができます。特に、Same-Site Cookie がデフォルトであるためです。

追加の保護として、Next.js 14 の Server Actions は Origin ヘッダーと Host ヘッダー (または X-Forwarded-Host) を比較します。一致しない場合、Action は拒否されます。つまり、Server Actions は、それらをホストするページと同じホストでしか呼び出せません。Origin ヘッダーをサポートしない、非常に古いサポートされていないブラウザはリスクにさらされる可能性があります。

Server Actions は CSRF トークンを使用しないため、HTML のサニタイズが不可欠です。

カスタムルートハンドラー (route.tsx) が使用される場合、CSRF 保護は手動で行う必要があるため、追加の監査が必要になる場合があります。そこでは従来のルールが適用されます。

エラーハンドリング

バグは発生します。サーバーでエラーがスローされると、UI で処理するためにクライアントコードで最終的に再スローされます。エラーメッセージとスタックトレースには、機密情報が含まれる可能性があります。たとえば、[credit card number] is not a valid phone number

本番モードでは、React はクライアントにエラーまたは拒否されたプロミスを送信しません。代わりにハッシュが送信され、エラーを表します。このハッシュを使用して、複数の同じエラーを関連付け、エラーをサーバーログに関連付けることができます。React はエラーメッセージを独自の汎用的なものに置き換えます。

開発モードでは、デバッグを容易にするために、サーバーエラーは引き続きプレーンテキストでクライアントに送信されます。

本番ワークロードについては、常に本番モードで Next.js を実行することが重要です。開発モードは、セキュリティとパフォーマンスのために最適化されていません。

カスタムルートとミドルウェア

カスタムルートハンドラーミドルウェアは、他の組み込み機能では実装できない機能のための低レベルのエスケープハッチと見なされます。これは、フレームワークが保護している可能性のあるフットガンも開きます。偉大な力には偉大な責任が伴います。

前述のように、route.tsx ルートはカスタム GET および POST ハンドラーを実装できます。これらは、正しく行われない場合、CSRF 問題の影響を受ける可能性があります。

ミドルウェアは、特定のページへのアクセスを制限するために使用できます。通常、これは拒否リストではなく、許可リストで行うのが最善です。これは、リライトやクライアントリクエストがある場合など、データにアクセスするさまざまな方法をすべて知ることが難しいためです。

たとえば、HTML ページのみを考慮することが一般的です。Next.js は、RSC/JSON ペイロードをロードできるクライアントナビゲーションもサポートしています。Pages Router では、これはカスタム URL にありました。

マッチメーカーの作成を容易にするために、Next.js App Router は、初期 HTML、クライアントナビゲーション、および Server Actions の両方で、常にページのプレーン URL を使用します。クライアントナビゲーションは、キャッシュブロッカーとして ?_rsc=... 検索パラメータを使用します。

Server Actions は、それらが使用されているページ上に存在するため、同じアクセス制御を継承します。ミドルウェアがページを読み取ることを許可する場合、そのページ上のアクションも呼び出すことができます。ページ上の Server Actions へのアクセスを制限するには、そのページで POST HTTP メソッドを禁止できます。

監査

Next.js App Router プロジェクトの監査を行っている場合、特に注意して確認するべきことがいくつかあります。

  • データアクセスレイヤー。 独立したデータアクセスレイヤーのための確立されたプラクティスはありますか?データベースパッケージと環境変数がデータアクセスレイヤー外にインポートされていないことを確認してください。
  • "use client" ファイル。コンポーネントのプロップはプライベートデータを期待していますか?型シグネチャは広範すぎませんか?
  • "use server" ファイル。アクション引数はアクション自体で検証されていますか、それともデータアクセスレイヤー内で検証されていますか?ユーザーはアクション内で再承認されていますか?
  • /[param]/。括弧で囲まれたフォルダはユーザー入力です。パラメータは検証されていますか?
  • middleware.tsxroute.tsx は多くの権限を持っています。従来の技術を使用して、これらの監査に時間をかけてください。ペネトレーションテストまたは脆弱性スキャンを定期的に、またはチームのソフトウェア開発ライフサイクルに合わせて実行してください。