結論
- 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 月中旬でした。原因は「壊れ方が静かすぎた」ことです。
- サーバー側の描画は成功している。本文も見出しも CSS も正しく出るので、ページを開いただけでは気づけない
- HTTP ステータスは全部 200。HTML もチャンクも正常に配信されている。監視で拾える異常が無い
-
開発環境では再現しない。開発時の CSP は HMR のために
'unsafe-inline'を使っていて、nonce を使わない。つまりnpm run devでは絶対に起きない - 壊れていたのがボタンだけだった。読み物としての用語集は、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)で静的化しているページがある方は、一度コンソールを開いてみることをおすすめします。