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

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エンドポイントに移動して、物事を一貫させることをお勧めします。

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

このアプローチにより、既存のバックエンドチームがセキュリティを専門とし、既存のセキュリティプラクティスを適用できる組織構造を維持できます。これらのチームが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にアクセスすべきです。

コンポーネントレベルデータアクセス

もう1つのアプローチは、データベースクエリを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にpropsを渡すときに自動的に行われます。このシリアライゼーションは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にデータが入らないようにすることがより良い方法です(データアクセス層を使用する)。汚染チェックは、値を指定することで間違いに対する追加の保護層を提供します。関数とクラスはすでにClient Componentsに渡せないようにブロックされていることに留意してください。より多くの層を設けることで、何か見落としがあるリスクを最小限に抑えます。

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

SSR vs RSC

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

Server Components (RSC) は、Client Componentsとは別のモジュールシステムで実行され、両モジュール間で情報が誤って公開されるのを防ぎます。

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

読み込み

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

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

Server Componentのレンダリングは、ミューテーションのような副作用を決して実行すべきではありません。これはServer Componentsに固有のものではありません。Reactは、クライアントコンポーネントをレンダリングする際も (useEffect以外では)、二重レンダリングなどを行うことで、副作用を自然と抑制します。

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

例:searchParamsは、変更の保存やログアウトなどの副作用を実行するために使用すべきではありません。代わりにServer Actionsを使用すべきです。

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

Next.jsは、カスタムルートハンドラー (route.tsx) をサポートしており、GETでクッキーを設定できます。これはエスケープハッチと見なされ、一般的なモデルの一部ではありません。これらは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();
  }
  ...
}

クロージャ

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

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は特定のビルドに対してのみ呼び出すことができます。再デプロイ中に常に正しいバージョンを呼び出すには、スキュー保護を使用することをお勧めします。

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

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

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

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

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

エラー処理

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

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

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

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

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

カスタムルートハンドラーミドルウェアは、他の組み込み機能では実装できない機能のための低レベルのエスケープハッチと見なされます。これは、フレームワークが保護するはずの潜在的な足元をすくう危険性ももたらします。大きな力には大きな責任が伴います。

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

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

例えば、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.tsxroute.tsxは非常に強力です。これらを従来の技術を使用して監査するために時間をかけてください。定期的に、またはチームのソフトウェア開発ライフサイクルに合わせて、ペネトレーションテストや脆弱性スキャンを実施してください。