CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する

CSP セキュリティ HTTPヘッダー Web XSS
結論
  • CSP をいきなり Content-Security-Policy で強制すると、広告タグ・分析 SDK・レガシー CDN など想定外の読み込み元で本番が止まりやすい。
  • Content-Security-Policy-Report-Only で違反だけ観測し、report-toReporting-Endpoints で集約 → triage → カナリア → 全量強制、の順で段階導入するのが安全だ。
  • 詳細なディレクティブ設計は CSP 設定ガイド を参照し、Report-Only はその手前を支える計測レイヤーと位置づける。

Report-Only モードとは

Content Security Policy(CSP)には 強制(Enforcement)Report-Only の 2 系統がある。

  • 強制モードContent-Security-Policy ヘッダー。ポリシー違反のリソース読み込みやスクリプト実行をブラウザがブロックする。
  • Report-Only モードContent-Security-Policy-Report-Only ヘッダー。同じルールを評価するがブロックはしない。違反が起きたときだけレポートを送信する。

本番サイトに初めて CSP を入れるとき、A/B テスト用スクリプト、タグマネージャ経由の第三者ドメイン、社内ツールが差し込む計測コードなど「棚卸し段階では見落としていた読み込み元」が必ず表れる。Report-Only ならユーザー体験を壊さずに、そのギャップを本番トラフィックのデータで可視化できる。

Report-Only は「セキュリティを一時的に緩める」手段ではない。将来適用する厳格ポリシーを事前にシミュレーションする観測レイヤーだ。Console に [Report Only] Refused to load... と表示されるが、リソースは実際には読み込まれる。ここが強制モードとの決定的な違いになる。

強制モードとの違い

観点強制モードReport-Only
ヘッダー名Content-Security-PolicyContent-Security-Policy-Report-Only
違反時の動作ブロック(読み込み・実行を拒否)レポート送信のみ(読み込みは継続)
本番リスク高(決済・ログイン・広告表示の停止)低(観測専用)
主な用途最終的な XSS 防御ポリシー設計・移行期の検証
Console 表示Refused to load...[Report Only] Refused to load...
フェーズ 推奨ヘッダー
調査・棚卸し Report-Only のみ。DevTools Network / Console も併用
レポート収集 Report-Only + report-to(Reporting-Endpoints 併記)
ポリシー調整 Report-Only を更新し、違反件数の減少を確認
カナリア移行 一部トラフィックだけ Content-Security-Policy を追加
本番完了 全トラフィックを強制モード。Report-Only は次期ポリシー検証用に残すことも

同じレスポンスに強制ヘッダーと Report-Only ヘッダーを併記できる。たとえば本番は script-src 'self' https://cdn.example.com 程度の緩い強制ポリシーで運用しつつ、script-src 'nonce-...' 'strict-dynamic' という将来版を Report-Only で検証する、という二段構えが現実的だ。

Report-Only ヘッダーの記述例

最小構成から、nonce を含む実務向けの例まで示す。

フェーズ 1 — 観測開始(緩めのポリシー)

Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-endpoint

default-src 'self' だけでも、想定外の外部ドメインからの読み込みはレポートに載る。最初から script-src を厳しくしすぎると、レポートが洪水になり triage が機能しなくなる。

フェーズ 2 — script-src を絞り込み

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; report-to csp-endpoint

フェーズ 3 — nonce + strict-dynamic を検証

Content-Security-Policy-Report-Only: default-src 'self'; script-src 'nonce-a1b2c3d4' 'strict-dynamic' https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-to csp-endpoint

nonce の生成方法や strict-dynamic の意味は CSP 設定ガイド で詳述している。Report-Only フェーズで先に nonce 配信を試しておくと、強制切り替え時の inline 違反を大幅に減らせる。

meta タグでは report-to は効かない

<meta http-equiv="Content-Security-Policy">frame-ancestors やレポート送信に非対応。Report-Only も HTTP レスポンスヘッダーで返す(CDN・リバースプロキシ・アプリサーバーで設定)。HTML に <meta> で CSP を書いている場合、Report-Only への移行はインフラ側の変更が必須になる。

report-to と Reporting-Endpoints の設定

CSP 違反レポートの送信先は、ポリシー内の report-to ディレクティブと、レスポンスヘッダーの Reporting-Endpointsセットで宣言する。

Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; report-to csp-endpoint

report-to の値(ここでは csp-endpoint)は、Reporting-Endpoints で定義した名前を参照する。URL は Reporting-Endpoints 側に書く。

複数エンドポイントを使う例

Reporting-Endpoints: csp-prod="https://reports.example.com/csp", csp-staging="https://staging-reports.example.com/csp"
Content-Security-Policy-Report-Only: default-src 'self'; report-to csp-prod

ステージングと本番でレポート先を分けたい場合、環境ごとに Reporting-Endpoints の URL だけ差し替える。ポリシー本体は同一に保てる。

report-uri との併用(レガシー互換)

Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; report-to csp-endpoint; report-uri https://reports.example.com/csp-legacy

report-uri は CSP Level 2 由来の古い方式で、仕様上は非推奨。Reporting API 非対応の古いブラウザ向けに併記するチームはあるが、新規では report-to を主とする。

report-to の利点は次のとおり。

  • Reporting API と統合され、CSP・NEL(Network Error Logging)・Deprecation など複数種別を同じインフラで扱える
  • エンドポイント名で抽象化でき、URL 変更時にポリシー文字列を書き換えずに済む
  • Chromium 系・Firefox で実装が進んでおり、モダンブラウザではこちらが主流

ブラウザが送信する違反レポート JSON

違反が発生すると、ブラウザは Reporting-Endpoints で指定した URL へ POST する。ペイロードは JSON で、Reporting API 形式(report-to 使用時)と CSP レガシー形式(report-uri 使用時)がある。

report-to 使用時(Reporting API 形式)

[
  {
    "age": 0,
    "body": {
      "blockedURL": "inline",
      "disposition": "report",
      "documentURL": "https://example.com/checkout",
      "effectiveDirective": "script-src-elem",
      "lineNumber": 87,
      "originalPolicy": "default-src 'self'; script-src 'self' 'nonce-abc123'; report-to csp-endpoint",
      "referrer": "https://example.com/cart",
      "sample": "console.log('tracking')",
      "sourceFile": "https://example.com/checkout",
      "statusCode": 200
    },
    "type": "csp-violation",
    "url": "https://example.com/checkout",
    "user_agent": "Mozilla/5.0 ..."
  }
]

report-uri 使用時(レガシー csp-report 形式)

{
  "csp-report": {
    "document-uri": "https://example.com/checkout",
    "referrer": "https://example.com/cart",
    "violated-directive": "script-src-elem",
    "effective-directive": "script-src-elem",
    "original-policy": "default-src 'self'; script-src 'self' 'nonce-abc123'; report-uri /csp-report",
    "blocked-uri": "inline",
    "source-file": "https://example.com/checkout",
    "line-number": 87,
    "column-number": 12,
    "status-code": 200
  }
}

フィールドの読み方

フィールド意味triage での使い方
blockedURL / blocked-uriブロック対象(または inline / evalinline ならインライン script、外部 URL なら CDN・広告ドメイン
effectiveDirective / effective-directive実際に評価されたディレクティブscript-src-elem は外部 script、script-src-attr はイベントハンドラ
sourceFile / source-file違反箇所のファイル URLHTML 内の行番号と合わせて修正箇所を特定
lineNumber / line-number行番号DevTools Sources と照合
sample違反した script の先頭(ブラウザ依存)inline script の内容確認に使える
documentURL / document-uri違反が起きたページどの URL パターンで多発するか集計

blocked-uriinline ならインライン <script> またはイベントハンドラ(onclick など)が原因。eval なら eval()new Function() の利用。サードパーティ広告が原因の場合は https://ads.example.net/... のような外部ドメインが入る。

段階導入の実務フロー

CSP 導入は「ポリシーを書く → デプロイ → 祈る」ではなく、計測と調整のサイクルで進める。

1

DevTools の Network / Console で現状の script・style・img・connect・font の読み込み元を棚卸しする

2

緩めの Report-Only ポリシー + report-to を全トラフィックに配信し、1〜2 週間レポートを収集する

3

レポートを triage し、許可漏れドメイン・unsafe-inline 依存・eval 利用を優先度付きでリスト化する

4

ポリシーを更新(nonce 導入、CDN ホワイトリスト追加、タグマネージャ整理など)し、再度 Report-Only で検証する

5

内部ユーザーまたは 5% トラフィックだけ Content-Security-Policy(強制)を有効化するカナリアを実施する

6

エラー率・問い合わせ・違反件数を確認し、問題なければ全トラフィックを強制モードへ切り替える

7

強制切り替え後も Report-Only を残し、次のデプロイで新タグの違反を先に検知する

「いきなり厳格ポリシー」は避ける。最初は default-src 'self'; report-to csp-endpoint 程度から始め、レポートで実際の違反を見ながら script-srcconnect-src を絞る方が、ポリシーの妥当性を担保しやすい。

収集期間の目安

  • 小規模サイト(PV 数万/月): 1〜2 週間
  • 中規模 EC / メディア: 2〜4 週間(曜日・キャンペーン・決済フローをまたぐ)
  • 多ページ SPA: 主要ルートごとに document-uri 別集計し、カバレッジ不足を確認

週末やセール期間は第三者タグの読み込みパターンが変わる。1 週間だけでは見落としが出やすい。

レポート triage ワークフロー

Console には Content Security Policy: ... violated と表示されるが、本番ではユーザー環境・ブラウザ拡張・地域 CDN の差で違反内容がばらつく。report-to で集約しないと、開発者の手元で再現できない違反を見落としがちだ。

triage の 5 ステップ

  1. 取り込み — レポート受信 API からログ基盤(CloudWatch、Datadog、BigQuery、自前 DB)へ流す。生 JSON は 30 日程度保持。
  2. ノイズ除去chrome-extension://moz-extension://、既知の bot UA を除外ルールに追加。
  3. 集計effectiveDirective × blockedURL × documentURL の上位 N 件を週次で抽出。
  4. 分類 — 下表のカテゴリに振り分け、担当(フロント / インフラ / マーケ)をアサイン。
  5. 修正 → 再検証 — ポリシー更新または HTML/タグ修正後、同じ集計クエリで件数減少を確認。

優先度の高い違反

優先度ディレクティブ理由
P0script-src(inline / eval)XSS の直撃面。unsafe-inline 依存の解消が最優先
P1connect-src意図しない API 送信・データ漏洩の検知
P1frame-ancestorsクリックジャッキング試行の可視化(Report-Only でもレポートは飛ぶ)
P2style-src(inline)XSS よりリスクは低いが、将来的な 'unsafe-inline' 排除に向けて把握
P3img-src / font-src表示崩れ要因。セキュリティより UX 影響が中心

ノイズになりやすいもの

  • ブラウザ拡張機能が注入するスクリプト(source-file が拡張 ID)
  • 開発者ツール・Lighthouse・合成監視の計量(本番レポートから UA フィルタで除外)
  • 旧キャッシュ HTML(デプロイ直後に一時的に nonce 不一致の inline 違反が増える)

週次ダッシュボードで violated-directive ごとの件数推移を見る。Report-Only 調整後に件数が減っていれば、ポリシー変更が効いているサインだ。横ばいなら、まだ未到達のページパターン(ログイン後、決済完了、管理画面)のカバレッジ不足を疑う。

カナリアロールアウト手順

Report-Only で違反が実質ゼロ(または許容リストに載せた既知の例外のみ)になったら、強制モードへの切り替えに入る。ここでいきなり 100% 切り替えは避け、ロールバック可能な単位で進める。

Step 1 — 社内ユーザーのみ強制

Cookie や認証ヘッダーで社内 VPN ユーザーだけ Content-Security-Policy を付与する。

# 社内 IP または X-Internal-User: 1 のときだけ強制ヘッダーを追加
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-...' 'strict-dynamic'; object-src 'none'
Content-Security-Policy-Report-Only: (同上または次期厳格版)
Reporting-Endpoints: csp-endpoint="https://reports.example.com/csp"

社内で決済テスト・管理画面・広告表示を一通り触り、ブロック起因の機能停止がないか確認する。

Step 2 — 5% トラフィックで強制

エッジ(Cloudflare Workers、Varnish、nginx split_clients)でリクエストの 5% だけ強制ヘッダーを返す。

split_clients "${remote_addr}${http_user_agent}" $csp_enforce {
    5%     "enforce";
    *      "report-only";
}

# $csp_enforce が enforce のとき Content-Security-Policy を追加
# それ以外は Report-Only のみ

5% 期間中に監視する指標:

  • フロントエンド JS エラー率(Sentry 等)
  • コンバージョン率・決済成功率
  • CSP 違反レポートの disposition: enforce 件数(強制モード特有)

Step 3 — 25% → 50% → 100%

各段階で 24〜48 時間様子を見る。問題があれば $csp_enforce の比率を 0% に戻すだけで即 Report-Only のみ運用に復帰できる。強制ヘッダーを外して Report-Only だけ残すロールバックは、デプロイ不要でエッジ設定変更のみで完結するのが理想。

Step 4 — 強制後も Report-Only を維持

全量強制に切り替えた後も、次期ポリシーまたは同一ポリシーの継続監視用に Report-Only を残す。次のデプロイで新しいタグマネージャ snippet が入ったとき、ブロックされる前に Report-Only レポートで気づける。

レポート受信エンドポイントの設計

違反レポートはブラウザから POST される。CORS プリフライトは通常不要(Reporting API の送信は仕様上シンプル)だが、大量の JSON POST を受け止める設計が必要になる。

最低限の要件

  • Content-Type: application/reports+json または application/csp-report を受理
  • レスポンスは 204 No Content または 200 OK(ボディ空)で十分
  • レート制限(IP / ドメイン単位)で flood 攻撃を防ぐ
  • 認証は必須ではないが、エンドポイント URL が漏れると第三者からの偽レポートも理論上可能。WAF ルールや内部ネットワーク限定を検討

Express の最小例

app.post('/csp', express.json({ type: ['application/csp-report', 'application/reports+json'] }), (req, res) => {
  const reports = Array.isArray(req.body) ? req.body : [req.body];
  for (const report of reports) {
    const body = report.body ?? report['csp-report'];
    logger.info({ csp: body });
  }
  res.status(204).end();
});

本番では構造化ログとして出力し、ログエージェント経由で集計基盤へ流す。Sentry の security モジュールや Datadog の CSP インテグレーションを使う選択肢もある。

サーバー・CDN 設定例

nginx

# Reporting-Endpoints(常に付与)
add_header Reporting-Endpoints 'csp-endpoint="https://reports.example.com/csp"' always;

# Report-Only(全トラフィック)
add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; report-to csp-endpoint" always;

# 強制モード(カナリア時のみ map / if で条件付き追加)
# add_header Content-Security-Policy "..." always;

Cloudflare Transform Rules(レスポンスヘッダー変換)

  • ルール名: CSP Report-Only
  • 条件: ホスト名 equals example.com
  • 操作: Reporting-Endpoints を Set、Content-Security-Policy-Report-Only を Set

段階導入初期は Transform Rules だけ有効にし、レポート受信は Workers または外部 SaaS に転送する。カナリア段階では Workers で Math.random() < 0.05 の分岐を入れ、5% だけ Content-Security-Policy を追加する実装が多い。

Vercel / Next.js(next.config.js 経由)

async headers() {
  return [{
    source: '/(.*)',
    headers: [
      { key: 'Reporting-Endpoints', value: 'csp-endpoint="https://reports.example.com/csp"' },
      { key: 'Content-Security-Policy-Report-Only', value: "default-src 'self'; script-src 'self'; report-to csp-endpoint" },
    ],
  }];
}

強制モードへの切り替えと CSP ガイドとの連携

Report-Only で収集・triage・修正を終えたら、Content-Security-Policy-Report-Only のディレクティブを Content-Security-Policy にコピーする。文字列は同一でよい。ヘッダー名だけが enforce と report-only で役割を分ける。

本番ポリシーの設計(nonce + strict-dynamic、frame-ancestors、connect-src の絞り込み)は CSP 設定ガイド を参照。Report-Only 段階で以下を先に試しておくと、強制切り替え時のブロック件数を抑えられる。

  • nonce 配信 — SSR またはエッジでリクエストごとに nonce を生成し、HTML の <script nonce="..."> とヘッダーを一致させる
  • strict-dynamic — 信頼 script から動的追加される script の連鎖許可
  • frame-ancestors'self' または 'none' でクリックジャッキング経路を閉じる
  • object-src ‘none’ — Flash 等のレガシープラグイン経路を遮断

XSS 攻撃と防御の完全ガイド と合わせて読むと、CSP が入力サニタイズ・出力エスケープとどう役割分担するかも整理しやすい。

よくある落とし穴

  • Report-Only だけで満足する — 観測は導入の途中。ゴールは強制モードでの XSS 防御。レポート件数ゼロが続いても、enforce に切り替えるまでは保護は効いていない。
  • report-to だけ書いて Reporting-Endpoints を忘れる — レポートは送信されない。両方セットで確認する。
  • nonce を静的 HTML にハードコード — CDN キャッシュで全ユーザー同一 nonce になり、XSS 防御として機能しない。Report-Only では違反が出ないように見えるが、enforce 後に問題化する。
  • カナリアなしの全量切り替え — 決済・広告・埋め込みウィジェットで部分障害が出たとき、影響範囲が全ユーザーに及ぶ。
  • triage なしのポリシー追加script-src にドメインを次々足す「ホワイトリスト肥大化」に陥る。レポートで根拠を持って追加する。

関連記事

この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。

トピック記事
CSRF対策の実装ガイドCSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード
Double Submit Cookie完全実装Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃
セキュリティヘッダー一覧と設定ガイドセキュリティヘッダー一覧と設定ガイド|CSP・HSTS・Referrer-Policy・Permissions-Policy
セキュアなCookie設定ガイドセキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策
CORSエラーの対処法CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する
CORSプリフライトリクエストCORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答
ローカル開発でCORSエラーが出る原因と3つの回避策ローカル開発でCORSエラーが出る原因と3つの回避策
OAuth2 PKCE SPA実装ガイドOAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン
Passkeys・WebAuthn実装の基礎Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server
JWTのデバッグ方法JWTのデバッグ方法|トークンの中身を安全に確認する
JWTの中身を確認する方法JWTの中身を確認する方法|改ざんが検知される仕組み
JWKSとJWT鍵ローテーション運用ガイドJWKSとJWT鍵ローテーション運用ガイド|kid検証・ゼロダウンタイム・Node.js実装

まとめ

Content-Security-Policy-Report-Only は、CSP を本番に安全に載せるための観測レイヤーだ。Reporting-Endpointsreport-to で違反を集約し、JSON レポートを triage しながらポリシーを磨き、5% カナリアから強制モードへ移行すれば、広告・分析・レガシー資産が絡むサイトでも機能を壊さずセキュリティを上げられる。

強制モードはゴール、Report-Only はその手前を支える計測インフラと考えると運用がぶれにくい。ディレクティブの詳細設計は CSP 設定ガイド に任せ、Report-Only フェーズでは「何がブロック対象になるか」をデータで確定させることに集中するのが実務的な進め方だ。