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

2023年10月23日(月)

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

投稿者

App Router の React Server Components (RSC) は、従来のメソッドに関連する冗長性と潜在的なリスクの多くを排除する新しいパラダイムです。新しさから、開発者やセキュリティチームは、既存のセキュリティプロトコルをこのモデルに合わせるのが難しいと感じるかもしれません。

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

データ処理モデルの選択

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

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

1つのアプローチに固執し、あまり混ぜ合わせないことをお勧めします。これにより、コードベースで作業する開発者と、セキュリティ監査者の両方が何を期待すべきかを明確にすることができます。例外は疑わしいものとして現れます。

HTTP API

既存のプロジェクトで Server Components を採用する場合、推奨されるアプローチは、SSR と同様に、またはクライアント内で、デフォルトで安全ではない/信頼できないものとしてランタイムで Server Components を処理することです。したがって、内部ネットワークや信頼ゾーンの仮定はなく、エンジニアはゼロトラストの概念を適用できます。代わりに、クライアントで実行している場合と同様に、Server Components から fetch() を使用して、REST や GraphQL などのカスタム API エンドポイントのみを呼び出します。クッキーを渡します。

データベースに接続する既存の getStaticProps/getServerSideProps がある場合、モデルを統合し、これらも API エンドポイントに移動して、1つの方法で実行できるようにすることが望ましいかもしれません。

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

このアプローチにより、既存のセキュリティに特化したバックエンドチームが既存のセキュリティプラクティスを適用できる既存の組織構造を維持できます。これらのチームが 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 のような props が含まれているかどうかを確認します。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 がこのモジュールをインポートしようとすると、ビルドでエラーが発生します。これは、機密性の高いコードや内部ビジネスロジックが誤ってクライアントに漏洩しないようにするために使用できます。

データを転送する主な方法は、Client Components に props を渡すときに自動的に発生する React Server 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;
}

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

データがそもそもサーバーコンポーネントに入り込まないように、データアクセスレイヤーを使用するのがより良い方法です。汚染チェックは、値の指定によって間違いに対する保護レイヤーを追加します。関数やクラスはすでにクライアントコンポーネントに渡すことがブロックされていることに注意してください。レイヤーを重ねることで、何かが漏れるリスクを最小限に抑えることができます。

デフォルトでは、環境変数はサーバーでのみ利用可能です。慣例として、Next.js はNEXT_PUBLIC_というプレフィックスが付いた環境変数をクライアントにも公開します。これにより、クライアントで利用可能にすべき特定の設定を明示的に公開できます。

SSR vs RSC

初回ロード時、Next.js はHTMLを生成するためにサーバーコンポーネントとクライアントコンポーネントの両方をサーバー上で実行します。

サーバーコンポーネント(RSC)は、2つのモジュール間で情報が誤って公開されるのを防ぐために、クライアントコンポーネントとは別のモジュールシステムで実行されます。

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

読み取り

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

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

サーバーコンポーネントのレンダリングは、ミューテーションなどの副作用を実行すべきではありません。これはサーバーコンポーネントに限ったことではありません。Reactは、ダブルレンダリングなどを行うことで、クライアントコンポーネントのレンダリング時(useEffectの外側)でさえ、副作用を自然に推奨していません。

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

たとえば、searchParamsは、変更の保存やログアウトなどの副作用を実行するために使用すべきではありません。代わりに、サーバーアクションを使用する必要があります。

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

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

書き込み

Next.js App Routerで書き込みやミューテーションを実行するための慣用的な方法は、サーバーアクションを使用することです。

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();
  }
  ...
}

クロージャー

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

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プロジェクトのビルド中に秘密鍵が自動的に生成されます。再構築ごとに新しい秘密鍵が生成されるため、各サーバーアクションは特定のビルドに対してのみ呼び出すことができます。スキュー保護を使用して、再デプロイ中に常に正しいバージョンを呼び出すようにすることをお勧めします。

より頻繁にローテーションするキーや、複数のビルド間で永続化するキーが必要な場合は、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>;
}

原則として、サーバーアクション("use server")への引数リストは常に敵対的であると見なす必要があり、入力は検証される必要があります。

CSRF

すべてのサーバーアクションは、プレーンな<form>によって呼び出すことができ、CSRF攻撃に対して脆弱になる可能性があります。舞台裏では、サーバーアクションは常にPOSTを使用して実装され、このHTTPメソッドのみがそれらを呼び出すことが許可されています。これだけでも、特にSame-Siteクッキーがデフォルトであるため、最新のブラウザーのほとんどのCSRF脆弱性を防ぎます。

追加の保護として、Next.js 14のサーバーアクションは、OriginヘッダーとHostヘッダー(またはX-Forwarded-Host)も比較します。一致しない場合、アクションは拒否されます。言い換えれば、サーバーアクションは、それをホストするページと同じホストでのみ呼び出すことができます。Originヘッダーをサポートしていない非常に古い、サポートされていない、時代遅れのブラウザーはリスクにさらされる可能性があります。

サーバーアクションはCSRFトークンを使用しないため、HTMLサニタイズが重要です。

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

エラー処理

バグは発生します。サーバーでエラーがスローされると、最終的にUIで処理されるためにクライアントコードで再スローされます。エラーメッセージとスタックトレースには、機密情報が含まれている可能性があります。例:[クレジットカード番号]は有効な電話番号ではありません

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

開発モードでは、デバッグに役立つように、サーバーエラーはプレーンテキストでクライアントに送信されます。

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

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

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

上記のように、route.tsxルートは、正しく行われない場合、CSRF問題が発生する可能性のあるカスタムGETおよびPOSTハンドラーを実装できます。

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

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

マッチャーの記述を簡単にするために、Next.js App Routerは、初期HTML、クライアントナビゲーション、サーバーアクションの両方で、常にページのプレーンURLを使用します。クライアントナビゲーションは、キャッシュブレーカーとして?_rsc=...検索パラメーターを使用します。

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

監査

Next.js App Routerプロジェクトの監査を行う場合、特に注意して確認することをお勧めする項目を以下に示します。

  • データアクセス層。分離されたデータアクセス層に対する確立されたプラクティスはありますか?データベースパッケージと環境変数がデータアクセス層外でインポートされていないことを確認してください。
  • "use client" ファイル。コンポーネントのpropsがプライベートデータを想定していますか?型シグネチャが過度に広範ではありませんか?
  • "use server" ファイル。アクションの引数はアクション内またはデータアクセス層内で検証されていますか?ユーザーはアクション内で再認証されていますか?
  • /[param]/。ブラケット付きのフォルダーはユーザー入力です。パラメータは検証されていますか?
  • middleware.tsx および route.tsx は非常に強力です。従来のテクニックを用いて、これらを監査するのに多くの時間を費やしてください。ペネトレーションテストまたは脆弱性スキャンを、定期的にまたはチームのソフトウェア開発ライフサイクルに合わせて実行してください。