0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本番でボタンが全部無反応だった——CSP の nonce と force-static は両立しない(Next.js App Router)

0
Posted at

結論

  • Next.js App Router で、middleware がリクエストごとに nonce を発行する CSP(script-src 'self' 'nonce-…' 'strict-dynamic')を使っていた
  • 同じアプリの一部のページに export const dynamic = 'force-static' が付いていた
  • ビルド時に生成された HTML には、リクエストごとの nonce を埋め込めない。結果、そのページだけ Next.js のチャンクもインラインスクリプトも全部 CSP でブロックされ、React が一度も起動しない
  • 見た目は正常、HTML も 200、CSS も JS のファイル自体も 200。壊れているのはブラウザのコンソールの中だけなので、6 週間気づかなかった
  • 直し方は force-static を外すだけ。あわせて「CSP に nonce があるなら、実行されるすべての <script> に同じ nonce が付いていること」を検査する smoke を足した

環境: Next.js 16.2.1 (App Router) / standalone ビルドを Docker で Azure App Service にデプロイ


症状

自分の個人開発サービスで、ログイン不要の公開ページとして「無料ツール」と「用語集」を出していました。ツールのページには、内容を Markdown でコピーするボタンと印刷ボタンがあります。

ある日ブラウザで開いて気づきました。「Markdown をコピー」を押しても、何も起きない。

  • ページは正常に表示される(サーバー側で描画された HTML がそのまま見えている)
  • HTTP ステータスは 200
  • CSS も当たっている
  • /_next/static/... のチャンクも、直接 URL を叩けば 200 で返る
  • エラー画面も出ない

コンソールを開くと、こうなっていました。

Loading the script 'https://example.com/_next/static/chunks/xxxx.js' violates the following
Content Security Policy directive: "script-src 'self' 'nonce-ZTk1…' 'strict-dynamic' https://js.stripe.com".
Note that 'strict-dynamic' is present, so host-based allowlisting is disabled.
The action has been blocked.

同じエラーが 25 件。Next.js のチャンクも、ハイドレーション用のインラインスクリプトも、全部ブロックされていました。つまり React が一度も起動していない。サーバーで描画された HTML が表示されているだけの、完全に静止したページでした。


仕組み: なぜ nonce と force-static は両立しないのか

この構成でやっていたこと

XSS 対策として、'unsafe-inline' を使わない CSP にしていました。middleware(proxy.ts)がリクエストごとに nonce を作り、CSP ヘッダーに載せます。

function buildCSP(nonce: string, isDev: boolean): string {
  const scriptSrc = isDev
    ? `'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com`
    : `'self' 'nonce-${nonce}' 'strict-dynamic' https://js.stripe.com`;

  return [
    "default-src 'self'",
    `script-src ${scriptSrc}`,
    // …
  ].join('; ');
}

nonce は x-nonce リクエストヘッダーで下流に渡します。Next.js は 13.4.20 以降、このヘッダーを見て自分が出力するスクリプトに nonce を付けてくれます。

// middleware 側
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
// app/layout.tsx
const headersList = await headers();
const nonce = headersList.get('x-nonce') ?? '';

リクエストごとに描画されるページなら、これで整合します。ヘッダーの nonce と HTML の nonce は同じ値です。

静的生成したページで何が起きるか

問題は、export const dynamic = 'force-static' を付けたページです。

HTML の nonce CSP ヘッダーの nonce 結果
リクエストごとに描画 そのリクエストの値 同じ値 一致するので実行される
ビルド時に静的生成 無い(ビルド時にリクエストは存在しない) リクエストごとの値 一致しないので全部ブロック

静的な HTML は「一度作って使い回す」ものなので、リクエストごとに変わる値を埋め込めません。一方で CSP ヘッダーは middleware が毎回付けるので、nonce は毎回変わります。噛み合うはずがありません。

'strict-dynamic' を使っていると、ホスト名による許可('self' など)は無視されます。つまり「自分のサーバーから配信しているから許す」が効かず、nonce が付いていないスクリプトは 1 つ残らず落ちます。


なぜ 6 週間気づかなかったのか

公開したのは 7 月末。気づいたのは 9 月中旬でした。原因は「壊れ方が静かすぎた」ことです。

  1. サーバー側の描画は成功している。本文も見出しも CSS も正しく出るので、ページを開いただけでは気づけない
  2. HTTP ステータスは全部 200。HTML もチャンクも正常に配信されている。監視で拾える異常が無い
  3. 開発環境では再現しない。開発時の CSP は HMR のために 'unsafe-inline' を使っていて、nonce を使わない。つまり npm run dev では絶対に起きない
  4. 壊れていたのがボタンだけだった。読み物としての用語集は、JS が死んでいても見た目は完全に正常

「HTML が 200 なら正常」という判定が、そのまま抜け穴になっていました。


直し方

該当ページから 1 行消すだけです。

- export const dynamic = 'force-static';
-
  export function generateStaticParams() {

これでリクエストごとの描画に戻り、Next.js が nonce を付けた HTML を返すようになります。

そもそも force-static を付けていたのは、別の理由からでした。standalone ビルドの Docker イメージにはリポジトリの content/ ディレクトリが含まれないため、ランタイムで fs を読む実装だと、キャッシュが切れた後にページが 404 になる——という事故を以前に踏んでいて、その再発防止として「完全に静的化する」方向に倒していたのです。

ただ、その後にデータの持ち方を「TypeScript/JSON の静的 import」に直していたので、ランタイムで fs を読む箇所はもう存在しませんでした。つまり force-static はもう不要で、CSP と噛み合わない害だけが残っていた、という状態でした。


再発防止: 検査を 1 つ足す

「見た目が正常でも壊れている」種類の不具合なので、人間の目視では防げません。CI や smoke で機械的に見るようにしました。

考え方はシンプルです。レスポンスの CSP ヘッダーに nonce があるなら、そのページの実行される <script> すべてに同じ nonce が付いていなければおかしい。

// CSP に nonce があれば、実行される script すべてに同じ nonce が付いていることを検証する
const paths = ['/', '/login', '/samples', '/tools/hearing-sheet', '/glossary/mvp'];

for (const p of paths) {
  const res = await fetch(BASE + p);
  const body = await res.text();

  const nonce = (res.headers.get('content-security-policy') || '').match(/'nonce-([^']+)'/)?.[1];
  if (!nonce) continue; // dev の CSP は nonce を使わないので検査対象外

  const scripts = [...body.matchAll(/<script\b[^>]*>/g)]
    .map((m) => m[0])
    .filter((tag) => !tag.includes('application/ld+json')); // JSON-LD は実行されないので除外

  const missing = scripts.filter((tag) => !tag.includes(`nonce="${nonce}"`));
  if (missing.length > 0) {
    throw new Error(`${p}: ${missing.length}/${scripts.length} 個の script に CSP nonce が無い`);
  }
}

ポイントは 2 つあります。

  • JSON-LD(application/ld+json)は除外する。データブロックであって実行されないので、nonce は不要です
  • 開発サーバーでは検査できない。開発時の CSP には nonce が無いため、この検査は素通りします。本番ビルド(next build + next start)か、実際のステージング/本番に対して流す必要があります

実際、この検査を修正前の本番に向けて流したら /tools/hearing-sheet: 25/25 個の script に CSP nonce が無い で落ちました。修正後は通り、ブラウザでもコピーボタンが「コピーしました」と反応するようになりました。


補足: nonce まわりでもう 1 つ出る警告

開発環境でコンソールを見ると、こんな警告が出ることがあります。

A tree hydrated but some attributes of the server rendered HTML didn't match the client properties.
...
+ nonce="Y2MxZmI4NDct…"
- nonce=""

これは React が、セキュリティ上の理由で nonce の値をクライアント側に持ち越さないために起きる差分です。壊れているわけではなく、本番では出ませんでした。今回の不具合とは別物なので、混同しないよう注意してください。


学んだこと

  • 'strict-dynamic' を使う CSP と、ページ単位の静的化は相性が悪い。どちらかを選ぶ必要がある
  • 「HTTP 200 かつ見た目が正常」は、正常の証明にならない。JS の実行まで含めて確認する必要がある
  • 開発環境で再現しない条件(CSP が緩い、ビルド方式が違う)は、そのまま死角になる。本番ビルドに対して流す検査を 1 つ持っておくと拾える
  • 「過去の別の事故への対策」が、環境が変わった後も残り続けて別の事故を生むことがある。対策を入れた理由が解消されたら、対策自体も見直す

この不具合を踏んだのは、曖昧な開発依頼を要件整理シートに変換する個人開発サービス「ReqLite」の公開ページでした。同じ構成(App Router + nonce ベースの CSP)で静的化しているページがある方は、一度コンソールを開いてみることをおすすめします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?