CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する
- CSP をいきなり
Content-Security-Policyで強制すると、広告タグ・分析 SDK・レガシー CDN など想定外の読み込み元で本番が止まりやすい。 - Content-Security-Policy-Report-Only で違反だけ観測し、
report-toとReporting-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-Policy | Content-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 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 / eval) | inline ならインライン script、外部 URL なら CDN・広告ドメイン |
effectiveDirective / effective-directive | 実際に評価されたディレクティブ | script-src-elem は外部 script、script-src-attr はイベントハンドラ |
sourceFile / source-file | 違反箇所のファイル URL | HTML 内の行番号と合わせて修正箇所を特定 |
lineNumber / line-number | 行番号 | DevTools Sources と照合 |
sample | 違反した script の先頭(ブラウザ依存) | inline script の内容確認に使える |
documentURL / document-uri | 違反が起きたページ | どの URL パターンで多発するか集計 |
blocked-uri が inline ならインライン <script> またはイベントハンドラ(onclick など)が原因。eval なら eval() や new Function() の利用。サードパーティ広告が原因の場合は https://ads.example.net/... のような外部ドメインが入る。
段階導入の実務フロー
CSP 導入は「ポリシーを書く → デプロイ → 祈る」ではなく、計測と調整のサイクルで進める。
DevTools の Network / Console で現状の script・style・img・connect・font の読み込み元を棚卸しする
緩めの Report-Only ポリシー + report-to を全トラフィックに配信し、1〜2 週間レポートを収集する
レポートを triage し、許可漏れドメイン・unsafe-inline 依存・eval 利用を優先度付きでリスト化する
ポリシーを更新(nonce 導入、CDN ホワイトリスト追加、タグマネージャ整理など)し、再度 Report-Only で検証する
内部ユーザーまたは 5% トラフィックだけ Content-Security-Policy(強制)を有効化するカナリアを実施する
エラー率・問い合わせ・違反件数を確認し、問題なければ全トラフィックを強制モードへ切り替える
強制切り替え後も Report-Only を残し、次のデプロイで新タグの違反を先に検知する
「いきなり厳格ポリシー」は避ける。最初は default-src 'self'; report-to csp-endpoint 程度から始め、レポートで実際の違反を見ながら script-src や connect-src を絞る方が、ポリシーの妥当性を担保しやすい。
収集期間の目安
- 小規模サイト(PV 数万/月): 1〜2 週間
- 中規模 EC / メディア: 2〜4 週間(曜日・キャンペーン・決済フローをまたぐ)
- 多ページ SPA: 主要ルートごとに
document-uri別集計し、カバレッジ不足を確認
週末やセール期間は第三者タグの読み込みパターンが変わる。1 週間だけでは見落としが出やすい。
レポート triage ワークフロー
Console には Content Security Policy: ... violated と表示されるが、本番ではユーザー環境・ブラウザ拡張・地域 CDN の差で違反内容がばらつく。report-to で集約しないと、開発者の手元で再現できない違反を見落としがちだ。
triage の 5 ステップ
- 取り込み — レポート受信 API からログ基盤(CloudWatch、Datadog、BigQuery、自前 DB)へ流す。生 JSON は 30 日程度保持。
- ノイズ除去 —
chrome-extension://、moz-extension://、既知の bot UA を除外ルールに追加。 - 集計 —
effectiveDirective×blockedURL×documentURLの上位 N 件を週次で抽出。 - 分類 — 下表のカテゴリに振り分け、担当(フロント / インフラ / マーケ)をアサイン。
- 修正 → 再検証 — ポリシー更新または HTML/タグ修正後、同じ集計クエリで件数減少を確認。
優先度の高い違反
| 優先度 | ディレクティブ | 理由 |
|---|---|---|
| P0 | script-src(inline / eval) | XSS の直撃面。unsafe-inline 依存の解消が最優先 |
| P1 | connect-src | 意図しない API 送信・データ漏洩の検知 |
| P1 | frame-ancestors | クリックジャッキング試行の可視化(Report-Only でもレポートは飛ぶ) |
| P2 | style-src(inline) | XSS よりリスクは低いが、将来的な 'unsafe-inline' 排除に向けて把握 |
| P3 | img-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-Endpoints と report-to で違反を集約し、JSON レポートを triage しながらポリシーを磨き、5% カナリアから強制モードへ移行すれば、広告・分析・レガシー資産が絡むサイトでも機能を壊さずセキュリティを上げられる。
強制モードはゴール、Report-Only はその手前を支える計測インフラと考えると運用がぶれにくい。ディレクティブの詳細設計は CSP 設定ガイド に任せ、Report-Only フェーズでは「何がブロック対象になるか」をデータで確定させることに集中するのが実務的な進め方だ。