セキュリティ診断でフロントエンドの残課題が「CSP未導入」の1点に絞られたので、3つのNext.jsサイトにContent-Security-Policy(CSP)を入れました。

ところが、同じ構成のはずなのに 管理系の2サイトは enforce で即入ったのに、Google Tag Manager(GTM)を使うユーザー向けサイトだけは enforce にできませんでした。「未リリースだから最初からenforceでいい」と考えていたのが、GTMの前で崩れたわけです。

この記事では、GTMを使うサイトがCSPを厳格化しづらい理由を、Google公式ドキュメントで裏取りします。そのうえで、enforce / Report-Only / 2ヘッダ併用の使い分けと、Next.js App Routerでの実装・検証方法をまとめます。

この記事で得られること

  • CSPが 何を防ぎ・何を防がないか(XSSの"被害"を減らす多層防御という位置づけ)
  • Next.js App RouterでCSPを付与する 最小実装(enforce / Report-Onlyの切り替え)
  • GTMを使うサイトで enforce が難しい理由(nonceでも救えない領域がある)
  • enforce / Report-Only / 2ヘッダ併用 の判断基準
  • enforceする場合の Google 公式推奨(nonce + strict-dynamic) とGA4の許可ドメイン、そして nonce のレンダリングコスト
  • connect-src など 壊しやすいディレクティブと、E2Eを使った回帰の切り分け

動作確認環境

種別バージョン
OSmacOS 15.x(開発)
Node.js24.x
パッケージマネージャーpnpm 9.x
Next.js15.x / 16.x(App Router)
React19.x
ホスティングNode ランタイム(headers() で付与)

CSP とは何で、何を守るのか

CSPは、ブラウザに対して「このサイトが読み込み・通信してよい相手はここだけ」と宣言する仕組みです。許可していないスクリプトの読み込みや外部への送信を、ブラウザ側がブロックします。

重要なのは、CSPは XSS そのものを無くす対策ではなく、XSS が起きたときの"被害"を抑える層だということです。仮に攻撃コードがページに紛れ込んでも、次のような動きを成立しにくくします。

攻撃者がやりたいことCSP なしCSP あり(enforce)
外部の悪意あるスクリプトを読み込む実行できる許可外ドメインは読み込めない
盗んだデータを外部へ送信する送信できる許可外への送信をブロック
透明フレームに埋めて誘導クリックさせる可能フレーム埋め込みを禁止
偽のフォーム送信先に情報を飛ばす可能送信先を自サイトに限定

鍵をかけるだけでなく、「万一入られても金庫の中身を運び出せない」状態を足すイメージです。入力のサニタイズやエスケープと併用して初めて効くという前提は忘れないでください。


管理系サイト:enforce は素直に入る

まずGTMを使っていない管理系サイトです。APIはすべて同一オリジンのBFFプロキシ(/api/proxy 経由)で叩いており、外部と通信するのは監視ツール(エラー収集)くらい。この構成ならenforceはほぼ素直に入ります。

ブラウザから見た通信経路は次のとおりです。外部APIを直接叩かず、すべて同一オリジンのBFFを経由するため、connect-src'self' に寄せられます。

flowchart LR
    A["ブラウザ\n(アクセストークンを持たない)"] -->|"同一 Origin の\n/api/proxy/*"| B["BFF プロキシ\n(Next.js Route Handler)"]
    B -->|"cookie から\nトークンを付与"| C["バックエンド API"]

Next.js App Routerでは next.config.tsheaders() で全ルートに付与します。Content-Security-Policy(enforce)と Content-Security-Policy-Report-Only(記録のみ)は キー名を変えるだけで切り替わります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// next.config.ts
const isDev = process.env.NODE_ENV === 'development';

const csp = [
  "default-src 'self'",
  "base-uri 'self'",
  "object-src 'none'",
  "frame-ancestors 'none'", // クリックジャッキング防止
  "form-action 'self'",
  // 'unsafe-eval' は開発時のみ。React がデバッグ用に eval を使うため必要で、
  // 本番では Next.js / React とも eval を使わないので付けない
  `script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ''}`, // 'unsafe-inline' は nonce 導入までの暫定
  "style-src 'self' 'unsafe-inline'",
  "font-src 'self' data:", // next/font はビルド時セルフホストなので外部フォント不要
  "img-src 'self' data: blob: https://cdn.example.com", // 画像 CDN のみ
  "connect-src 'self' https://*.ingest.example-monitoring.com", // API は same-origin。外部は監視のみ
  "frame-src 'self'",
].join('; ');

const nextConfig: NextConfig = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
          { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
          { key: 'Content-Security-Policy', value: csp },
        ],
      },
    ];
  },
};

'unsafe-eval' を開発時だけに寄せているのは、Next.js公式のCSPガイドが「本番では不要(ReactもNext.jsも既定で eval を使わない)」と明言しているからです。必要なのは開発時だけで、公式サンプルも同じ条件分岐になっています。本番設定に恒久的に残すと、CSPを理由なく弱めることになります。

ただし正直に書くと、'unsafe-inline' が入っている時点で この設定の script-src はXSS防御としてほとんど効いていません。ここでenforceが効いているのは connect-src / form-action / frame-ancestors / object-src の側です。script-src まで効かせるにはnonceが要ります(後述)。

ポイントは connect-src。APIを同一オリジンのBFF経由に寄せていたおかげで、外部通信は監視ツールだけになり、connect-src を絞れました。connect-src は CSP の中でいちばん壊しやすく、いちばん効くディレクティブです(後述)。

許可元は推測で足さず、コードから実際に参照している外部オリジンを洗い出してから入れます。

1
2
3
4
# 外部オリジンの棚卸し
grep -rEoh 'https://[a-zA-Z0-9.*_-]+' src next.config.ts | sort -u
# GTM/GA/監視ツールの利用有無
grep -rilE 'googletagmanager|google-analytics|sentry' src

GTM を使うサイト:ここで enforce が崩れる

問題はユーザー向けサイトです。ここはGTMを使っていて、マーケティング側がデプロイなしに GTM の管理画面からタグを追加する運用でした。

最初は「GTMのドメインを許可元に足せば済む」と思っていました。ところがGTMは「タグマネージャ」という名前のとおり、実行時にタグ(=外部スクリプトや計測ビーコン、ピクセル、広告 iframe)を動的に注入する仕組みです。しかも何を注入するかは管理画面の設定次第で、コードには現れません。

enforceにすると、許可元リストにないタグはブロックされます。違反自体はブラウザのコンソールに出ますが、画面上は何も壊れて見えず、タグを追加したマーケ側も利用者もコンソールは見ません。マーケがタグを1つ足しただけで計測が欠け、しかも誰も気づかない——これは最悪です。

「nonce を使えば解決」ではなかった

CSPには注入元を指定して除外する機能はありません(誰が入れたかは判別せず、リソースの種類・送信先URL・nonce/hashだけで判定します)。ではGTM公式の推奨はどうなっているのか。Google 公式の CSP ガイドを確認しました。

GTMガイドが推奨しているのは nonce です。“The recommended method is to use a nonce” とあり、Tag Managerが注入するスクリプトへnonceを伝播すると明記されています。

これに、Googleが別途strict CSPとして推奨する 'strict-dynamic'web.devのstrict CSPガイド)を組み合わせるのが実務上の構成です。GTMガイド自体は 'strict-dynamic' に触れていないので、出典は2つに分かれます。

リクエストごとに生成したnonceをGTMのスニペットとCSPヘッダの両方に付けると、GTMが動的に読み込むスクリプトは個別にドメイン列挙しなくても通ります(GTMがnonceを伝播する)。

ただし、ここに落とし穴があります。nonce + strict-dynamicが効くのは script-src だけ。GTMタグの本体がやることを分解すると、こうなります。

GTM タグの動作効くディレクティブnonce で救えるか
スクリプト読み込みscript-src✅ 救える
計測ピクセル(画像)img-src❌ 別途ドメイン許可が必要
ビーコン送信(fetch/beacon)connect-src❌ 別途ドメイン許可が必要
広告 iframeframe-src❌ 別途ドメイン許可が必要

Google公式も、GA4について img-src / connect-src に許可すべきドメインを明示的に列挙しています。GA4で必要な主なドメインは次のとおりです。

  • *.google-analytics.com / *.analytics.google.com
  • *.g.doubleclick.net / *.google.com
  • *.google.<TLD>(日本なら *.google.co.jp
  • pagead2.googlesyndication.com はGA4公式の connect-src リストに無条件で含まれる。Google広告を使うなら googleadservices 系も追加

3つ目の *.google.<TLD> は見落としやすいところです。日本のユーザーはGA4や広告のトラフィックが google.co.jp に飛ぶため、ここが抜けているとブロックされます。

つまり 「nonce さえあれば任意タグの通信先まで自動で通る」わけではないのです。

これが決定打でした。「任意のドメインのタグをデプロイなしで足す」運用と、厳格な enforce は根本的に両立しません。新しいタグの送信先(connect-src)を事前に許可できない以上、enforce下では新タグの計測が静かに壊れます。


判断:enforce / Report-Only / 2ヘッダ併用

ここまでを踏まえて、サイトの性質で使い分けました。判断の流れは次のとおりです。

flowchart TD
    A[CSP を導入する] --> B{外部タグを動的に\n足す運用?(GTM 等)}
    B -- いいえ --> C["enforce\n(許可外をブロック)"]
    B -- はい --> D{タグ追加時にドメインを\n申告してもらえる?}
    D -- はい --> E["enforce\n(nonce + strict-dynamic\n+ 公式ドメイン)"]
    D -- いいえ --> F{確実な防御だけ\n先に効かせたい?}
    F -- はい --> G["2ヘッダ併用\n(object-src 等は enforce /\n計測系は Report-Only)"]
    F -- いいえ --> H["Report-Only\n(+違反収集先)"]

各選択肢の中身は次のとおりです。

選択肢内容向いているケース
enforce許可外を実際にブロック外部タグを動的に足さないサイト(管理系・社内向けなど)
Report-Only違反を記録するだけでブロックしないGTM 等でマーケが任意タグを足す運用のサイト
2ヘッダ併用object-src / frame-ancestors / base-uri / form-action は enforce、script-src / img-src / connect-src / frame-src は Report-Only「クリックジャッキング等の確実な防御だけ先に効かせたいが、計測は壊したくない」中間ケース

CSPはenforceとReport-Onlyを別ヘッダで同時に送れるので、3つ目の折衷が可能です。「フレーム埋め込み禁止やフォーム送信先の固定といった、タグ運用と衝突しない防御」だけをenforceし、GTMが触る領域はReport-Onlyで観測する、という切り分けができます。

私のケースでは、ユーザー向けサイトは当面 Report-Only を選びました。connect-src を全開(https:)にしてenforceする案も検討しています。ただしそれだと、このサイトで狙っていた connect-src の実効防御が消えますobject-src / frame-ancestors / form-action は残ります)。一方でタグが静かに壊れる運用リスクは背負うことになるため、得るものと運用コストが釣り合わないと判断しました。

なお「確実な防御だけは先に効かせたい」場合、connect-src を全開にしたenforceよりも、上の表の2ヘッダ併用のほうが素直な選択です。将来ユーザー向けサイトをenforceにするなら、マーケの運用変更(タグ追加時に使用ドメインを申告して CSP を更新する)を合意してからにする、と決めました。

補足:Report-Onlyを"観測"として活かすには、違反を集める先(report-uri / report-to)が要ります。ここを用意していないと、違反は各ユーザーのブラウザコンソールに出るだけで、どこにも溜まりません。観測フェーズを回すなら収集先の準備までセットです。


他社・公式の実践に照らして

この判断が独りよがりでないか、公式ガイドと他社事例で確認しました。

OWASPは、enforce用とReport-Only用の2つのCSPヘッダを同時に送る構成をブラウザが問題なくサポートしていると述べています。活用例として挙がっているのは「厳格なReport-Onlyで違反を多く集めつつ、緩いenforceで正規の機能を壊さない」パターンです(OWASP CSP Cheat Sheet)。

OWASPの例は同じポリシーを厳しさ違いで2本出す形です。本記事の2ヘッダ併用はディレクティブを2本に分ける形なので、同一ではありません。ただ「2ヘッダを同時に送って、壊さずに効かせる」という発想は共通で、その応用にあたります。

Report-Onlyを"観測"として回すには収集先が要ります。Classiの事例では、report-uriで違反をSentryに送ってイシュー化し、Slackに通知する運用を組んでいます(nonceはnginxのsub_filterで注入)。

大規模サイトのGMOの記事も、「Report-Onlyの長期運用」「重要な部分からの段階導入」を前提に挙げています。

nonceを使う場合、OWASPは「全scriptタグを機械的にnonce置換するmiddlewareを作るな」と警告しています(前述)。nonceはテンプレート側で正規のタグにのみ付与します。Googleのstrict CSPガイドもnonce + strict-dynamicを推奨しています。なお csp.withgoogle.com 版は現在「outdated」と明示され、このweb.devのページへ誘導されます。

総じて、「外部タグ運用のあるサイトはReport-Only(+収集先)、確実な防御だけ2ヘッダで先行enforce」という方針は、公式・他社の実践と整合していました。


enforce する場合の実装(Google 推奨版)

将来ユーザー向けサイトをenforceに切り替える、あるいはGTMを使う別サイトをenforceにする場合の実装です。Next.jsではnonceをリクエストごとに生成するため、リクエスト前に走る層を使います。ファイル名はバージョンで変わります。Next.js 15 では middleware.ts、Next.js 16 では proxy.ts です。v16.0.0で middleware はdeprecatedとなり proxy へリネームされました。既存コードの移行は npx @next/codemod@canary middleware-to-proxy . で機械的にできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// proxy.ts(Next.js 15 なら middleware.ts)で nonce を生成し、
// ヘッダとページ(GTM スニペット)に渡す前提
const csp = [
  "default-src 'self'",
  "base-uri 'self'",
  "object-src 'none'",
  "frame-ancestors 'none'",
  "form-action 'self'",
  // GTM のスクリプトは nonce 伝播 + strict-dynamic で通す
  // 'https:' は 'strict-dynamic' 非対応、'unsafe-inline' は nonce 非対応の
  // 古いブラウザ向けフォールバック。いずれも対応ブラウザでは無視される
  `script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
  "style-src 'self' 'unsafe-inline'",
  "font-src 'self' data:",
  // 通信先は公式リスト通りに列挙(GA4 の例。Google 広告を使うなら googleadservices も追加)
  // 'https://*.google.co.jp' は公式リストの https://*.google.<TLD> に相当。日本向けは必須
  "img-src 'self' data: blob: https://www.googletagmanager.com https://*.google-analytics.com https://*.g.doubleclick.net https://*.google.com https://*.google.co.jp",
  "connect-src 'self' https://www.googletagmanager.com https://*.google-analytics.com https://*.analytics.google.com https://*.g.doubleclick.net https://*.google.com https://*.google.co.jp https://pagead2.googlesyndication.com",
  "frame-src 'self' https://www.googletagmanager.com",
].join('; ');

注意点が3つあります。

1つ目、そしてenforce 側の最大のコストが、nonceは 全ページを動的レンダリングに強制することです。Next.js公式は「When you use nonces in your CSP, all pages must be dynamically rendered」と明記しています。nonceはリクエストごとに変わるので、ビルド時に生成された静的ページには埋め込めないためです。影響は次のとおりです。

  • Static最適化とISRが無効になる
  • CDNでキャッシュできない
  • PPR(Partial Prerendering)とは非互換
  • 毎リクエストがサーバーレンダリングになり、初期表示の遅延・サーバー負荷・ホスティングコストが増える

「enforceにするか」を判断する際は、セキュリティだけでなくこのレンダリングコストも天秤に載せる必要があります。

静的生成を保ったまま script-src を厳格化したいなら、Next.jsの実験的な SRI ハッシュ方式experimental.sri)が代替候補です。ビルド時にスクリプトのハッシュを生成して integrity 属性を付ける方式で、静的生成とCDNキャッシュを維持できます。ただし実験的機能であり、App Router限定・ビルド時に確定するスクリプトのみ、という制約があります。

2つ目は'unsafe-eval'です。GTMのCustom JavaScript変数を使っているとCSP下でundefinedになり、'unsafe-eval'が必要になります(strict-dynamicの利点が薄れる)。Google公式はCustom Templateへの置き換えを推奨しています。

3つ目はnonceの付け方です。nonceは正規のスクリプトにだけ、テンプレート側で付与します。OWASPは「全scriptタグを機械的にnonce置換するmiddlewareを作るな」と警告しています(攻撃者が注入したスクリプトにまでnonceが付いてしまうため)。「proxy.ts / middleware.ts で生成」とはnonceのを生成してテンプレートに渡す意味で、DOM中のscriptを一括置換することではありません。


検証:enforce にする前に必ず通す

enforceは「許可漏れ=機能ブロック」なので、切り替え前の検証が要です。私は次の順で確認しました。

1
2
3
4
5
6
7
8
9
# ① ビルドが通る(設定・型の妥当性)
pnpm build
# ② 本番サーバの応答ヘッダに CSP が乗る(起動を待ってから叩く)
pnpm start &
until curl -sf -o /dev/null http://localhost:3000/; do sleep 1; done
curl -sI http://localhost:3000/ | grep -i content-security-policy
# ③ ブラウザで CSP 違反が出ないか(主要画面を巡回してコンソール確認)
# ④ E2E スイート(認証込み・全ページ)を CSP 下で流し、退行が無いことを確認
pnpm e2e

違反が出るとコンソールに Refused to ... violates the following Content Security Policy directive のように表示されます。表示されたオリジンを該当ディレクティブに足して再確認します。

一番効いたのは ④ の E2E を CSP 下で流すことです。認証込みで全ページを踏むので、connect-src の許可漏れのような「その画面に行かないと分からない」ブロックを機械的に洗い出せます。

ひとつハマったのが、ローカルでE2Eを並列実行すると、開発サーバのオンデマンドコンパイルが重なって waitForURL がタイムアウトし、CSPとは無関係なflakyが出たことです。これを「CSPのせい」と誤認しないために、次の2通りで切り分けました。

  • CSPなしの状態(baseline)で同じテストを流す
  • --workers=1(並列を切って)流す

結果、失敗はすべて負荷由来のflakyで、CSP起因ではないと確認できました。原因を決めつけず、比較で切り分けるのはCSPに限らず有効です。


ハマりどころまとめ

症状原因対処
画面が白い / JS が動かないNext.js のインライン script がブロックscript-src に nonce(または暫定で 'unsafe-inline')。'unsafe-eval' は開発時のみで本番には付けない
API が全部失敗connect-src に送信先が無いsame-origin proxy なら 'self'、直叩きなら API ドメインを追加
計測 / 広告が飛ばないGTM 注入先が未許可GA4/広告のドメインを img/connect/frame に追加、または Report-Only 運用
フォントが出ない外部フォント未許可Google Fonts 直リンクなら font-src/style-src に追加。next/font なら不要
Web Worker が動かないworker-src(blob)未許可worker-src 'self' blob: を追加

まとめ

  • CSPは XSS の被害を減らす層。入力サニタイズと併用が前提。
  • 外部タグを動的に足さないサイト(管理系)は enforce が素直に入るconnect-src を絞れるかがカギ。
  • GTM でマーケが任意タグを足す運用のサイトは、enforce と根本的に相性が悪い。nonce + strict-dynamicで"スクリプト"は救えても、“通信先(connect/img/frame)“は結局ドメイン許可が要り、任意タグを事前に許可できないため。
  • 迷ったら enforce / Report-Only / 2ヘッダ併用 の3択で、サイトの運用実態に合わせる。
  • nonceでenforceする場合は 全ページが動的レンダリングになる(ISR・CDNキャッシュ・PPRを失う)。セキュリティだけでなくこのコストも判断材料に。
  • enforce切り替え前は E2E を CSP 下で全ページ流すのが最も確実。flakyはbaselineと --workers=1 で切り分ける。

「未リリースだから全部enforceでいい」と最初は考えていましたが、リリースの有無ではなく サイトのタグ運用が判断軸でした。同じ構成でも、外部タグを誰がどう足すかでCSPの入れ方は変わります。

参考