Open1

Next.jsのセキュリティについてのメモ

TakuyaTakuya

これを読んでの雑多なメモ
https://nextjs.org/blog/security-nextjs-server-components-actions
ほぼ和訳(GPT-4とDeepL)あとで、きちんと読む

App RouterにおけるReact Server Components(RSC)は、従来の方法に関連する冗長性と潜在的なリスクの多くを排除する、新しいパラダイム。

その新しさゆえに、開発者とそれに続くセキュリティ・チームは、既存のセキュリティ・プロトコルをこのモデルに整合させることに困難を感じるかもしれない。
→RSCについてわかってないからよくわからないな

この文書は、注意すべきいくつかの領域と、どのような保護が組み込まれているかを強調し、アプリケーションを監視するためのガイドを含めることを意図しています。特に、思いがけないデータ漏洩のリスクに焦点を当てます。

Choosing Your Data Handling Model

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

まず最初に行うべきことは、プロジェクトに適切なデータ処理アプローチを選ぶことです。

  • HTTP APIs(既存の大規模プロジェクト/組織向けに推奨)
  • データアクセス層(新しいプロジェクト向けに推奨)
  • コンポーネントレベルのデータアクセス(プロトタイピングや学習用に推奨)

1つのアプローチに統一し、あまり混在させないことをお勧めします。
→これは大事、データ処理のアプローチの統一

これにより、コードベースで作業している開発者とセキュリティ監査をするの両方に期待するものが明確になります。急な例外が発生すると疑いを持たれてしまう

HTTP APIs

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

既存のgetStaticProps/getServerSidePropsがデータベースに接続していた場合、これらをAPIエンドポイントに移動して、一貫した方法で実行できるようにすることを検討してください。

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

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

クライアントに送信されるコード量を削減し、データウォーターフォールが低レイテンシで実行されるというServer Componentsの多くの利点を活用することができます。

Data Access Layer

新しいプロジェクトに対しては、JavaScriptコードベース内に別のData Access Layerを作成し、そこにすべてのデータアクセスを統合することをお勧めします。このアプローチにより、一貫したデータアクセスが確保され、認可バグの発生が減少します。また、単一のライブラリに統合するため、メンテナンスも容易になります。単一のプログラミング言語でチームの結束力が向上する可能性もあります。さらに、ランタイムオーバーヘッドが低く、リクエストのさまざまな部分でインメモリキャッシュを共有できるというより良いパフォーマンスを利用できます。

呼び出し元に与える前に、カスタムデータアクセスチェックを提供する内部JavaScriptライブラリを構築します。HTTPエンドポイントと同様ですが、同じメモリモデル内にあります。すべてのAPIは、現在のユーザーを受け入れ、ユーザーがこのデータを見ることができるかどうかを確認してから返す必要があります。原則として、Server Component関数本体は、リクエストを発行している現在のユーザーがアクセス権を持っているデータだけを表示するべきです。

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

data/auth.tsx
import { cache } from 'react';
import { cookies } from 'next/headers';
 
// ヘルパー・メソッドをキャッシュすることで、多くの場所で同じ値を使い回すの簡単
// 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 { 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);
});

これらの方法は、クライアントにそのまま安全に転送できるオブジェクトを公開する必要があります。これらのオブジェクトは、クライアントで消費される準備ができていることを明確にするために、データ転送オブジェクト(DTO)と呼んでいます。実際には、サーバーコンポーネントだけがこれらを利用するかもしれません。これにより、セキュリティ監査は主にデータアクセス層に焦点を当てることができ、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つのアプローチは、データベースクエリを直接サーバーコンポーネントに配置することです。このアプローチは、迅速な反復処理やプロトタイピングに適しています。例えば、リスクとそれらを監視する方法を全員が理解している小さなチームの小さな製品の場合です。

このアプローチでは、「use client」ファイルを注意深く監査する必要があります。監査や PR のレビュー時に、すべてのエクスポートされた関数を確認し、型シグネチャが User のような広範囲のオブジェクトを受け入れるか、tokencreditCard のようなプロパティが含まれているかどうかを見てください。「phoneNumber」のようなプライバシーに敏感なフィールドも特別な注意が必要です。クライアントコンポーネントは、その仕事を遂行するために必要な最小限のデータ以上のデータを受け入れるべきではありません。

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インジェクション攻撃を回避するために、常にパラメータ化されたクエリを使用するか、それを行ってくれるデータベースライブラリを使用してください。

サーバー専用

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

import 'server-only';

この方法では、クライアントコンポーネントがこのモジュールをインポートしようとするとビルドエラーが発生します。これにより、プロプライエタリ/機密性の高いコードや内部のビジネスロジックが誤ってクライアントに漏れるのを防ぐことができます。

データを転送する主要な方法は、React Server 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;
}
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;
}

ただし、これでも導出された値はブロックされません。データアクセスレイヤーを使用して、最初からサーバコンポーネントにデータが入らないようにする方が良いです。Taintチェックは、関数やクラスがすでにクライアントコンポーネントへの渡しがブロックされていることを念頭に置いて、値を指定することで誤りに対する追加の保護層を提供します。何かが抜けてしまうリスクを最小限に抑えるためのより多くの保護層があります。

デフォルトでは、環境変数はサーバ上でのみ利用可能です。慣例として、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からマップされた動的なparams、およびヘッダーです。これらは、クライアントによって異なる値に悪用される可能性があります。それらを信用せず、毎回読み取る際に再確認する必要があります。例えば、searchParamは?isAdmin=trueのようなものを追跡するために使用すべきではありません。ユーザーが/[team]/にいるからと言って、そのチームにアクセスできるわけではありません。データを読み込む際にそれを検証する必要があります。原則として、データを読み取るたびにアクセス制御とcookies()を常に再読み込みします。propsやparamsとして渡さないでください。

サーバコンポーネントをレンダリングする際には、変更などの副作用を伴う操作を行わないようにしなければなりません。これは、サーバコンポーネントに固有のものではありません。Reactは、(useEffectの外で)クライアントコンポーネントをレンダリングする際にも、ダブルレンダリングを行うことで副作用を自然に抑制しています。

さらに、Next.jsでは、レンダリング中にクッキーを設定したりキャッシュの再検証をトリガーしたりする方法はありません。これは、変更用のレンダリングの使用も妨げています。

例えば、searchParamsを使用して変更を保存したりログアウトしたりするような副作用を発生させるべきではありません。代わりに、サーバアクションを使用すべきです。

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

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

Write

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

Closures

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

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プロジェクトのビルド中にプライベートキーが自動的に生成されます。各リビルドで新しいプライベートキーが生成されるため、各サーバーアクションは特定のビルドでのみ呼び出すことができます。再デプロイ中に常に正しいバージョンを呼び出していることを確認するために、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>;
}

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

CSRF

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

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

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

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

エラーハンドリング

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

本番モードでは、Reactはクライアントにエラーや拒否されたプロミスを送信しません。代わりに、エラーを表すハッシュが送信されます。このハッシュを使用して、同じエラーの複数のインスタンスを関連付け、エラーをサーバーログに関連付けることができます。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には多くの力があります。従来の手法を使用してこれらを追加で監査するために、追加の時間を費やしてください。定期的にペネトレーションテストや脆弱性スキャンを実施するか、チームのソフトウェア開発ライフサイクルに合わせて実施してください。