WordPress に Content-Security-Policy を適用しながら動作を確認
サイトのセキュリティを高めるうえで強力な手段となるのが、Content-Security-Policy(CSP)の適用です。しかし、WordPress はコアやテーマ、プラグインによって多くのインラインスクリプトや外部リソースを動的に読み込むため、CSP を厳密に設定すると正常に表示・動作しないケースも少なくありません。
以下では、WordPress の標準テーマ「Twenty Twenty-Five」を新規インストールした環境をもとに、何もプラグインを入れていない状態から CSP を適用し、どのような CSP エラーが発生するのか、そしてどのように設定を調整すればエラーをなくしつつセキュリティを保てるのかを解説します。
さらに、Google reCAPTCHA v3 を導入したケースを例に外部スクリプトの許可方法を紹介し、レポート収集用の「Report-Only」モードの使い方、レポート送信先の設定例、レポートを収集・表示できるビューアーや CSV ダウンロード対応の実装サンプルまで掲載しています。
更新日:2025年07月04日
作成日:2025年07月02日
Content-Security-Policy とは
Content-Security-Policy(CSP)は、ウェブページがどのようなリソースを読み込むことができるかを制御するためのセキュリティ機能です。
CSP は HTTP レスポンスヘッダーや <meta> タグで設定でき、ブラウザに対して「どのリソースを、どのソース(ドメインなど)から読み込むことを許可するか」を指示します。
CSPを適切に設定することで、特に次のような攻撃を防止できます。
- クロスサイトスクリプティング(XSS)
- データインジェクション攻撃(例:HTMLやJSONの改ざん)
- 悪意あるスクリプトやフレーム埋め込み
ただし、CSP の設定を誤ると以下のような課題もあります。
- リソースが読み込めず、サイトの一部機能が動作しなくなる。
- インラインスクリプトやスタイルを許可するために unsafe-inline を追加すると CSP の効果が大きく損なわれる可能性がある。
また、report-to(または report-uri)を利用して違反レポートを収集することで、CSP の改善に役立てることができます。
CSP 設定例
例えば、以下のように設定すると(以下は説明用のもので、直接使える形式ではありません)
Content-Security-Policy:
default-src 'self';
script-src 'self' https://cdn.example.com https://analytics.example.com;
style-src 'self' https://fonts.googleapis.com;
img-src 'self' data: https://images.example.com;
font-src https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
frame-ancestors 'self';
report-uri /csp-report-endpoint;
このポリシーでは、以下のような設定が適用されます。
- ページに読み込まれるすべてのリソースのデフォルトの許可先を 'self'(= 自身のドメイン)に制限
- スクリプトは自身のドメイン(self)、および2つの外部ドメイン(CDN・解析)からのみ読み込み可能
- スタイルは自身とGoogle Fontsからのみ読み込み可能
- 画像は自身、data URI(インライン画像)、指定した画像CDNからのみ許可
- フォントは Google Fonts のフォント配信用ドメインのみ許可
- connec tは自身の API サーバーへの通信のみ許可
- 他サイトからの iframe 埋め込みはブロック(frame-ancestors 'self')
- 違反が発生した場合 /csp-report-endpoint にレポートが送信される
上記のポリシーを実際に適用するには、環境に応じて次のように書き換える必要があります。
Header set Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com https://analytics.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data: https://images.example.com; font-src https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'self'; report-uri /csp-report-endpoint;"
- Header set を使うことでレスポンスヘッダーを付加できます。
- Apacheのmod_headersモジュールが有効になっている必要があります。
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.example.com https://analytics.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data: https://images.example.com; font-src https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'self'; report-uri /csp-report-endpoint;";
<?php
header("Content-Security-Policy: default-src 'self'; "
. "script-src 'self' https://cdn.example.com https://analytics.example.com; "
. "style-src 'self' https://fonts.googleapis.com; "
. "img-src 'self' data: https://images.example.com; "
. "font-src https://fonts.gstatic.com; "
. "connect-src 'self' https://api.example.com; "
. "frame-ancestors 'self'; "
. "report-uri /csp-report-endpoint; ");
- header() は HTTP レスポンスの送信前に呼び出す必要があります(HTML 出力より前でないと無効)。
- 条件によって CSP ポリシーを切り替えることも可能です。
- .(ドット)を使って見やすく記述できます。
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://cdn.example.com https://analytics.example.com; style-src 'self' https://fonts.googleapis.com; img-src 'self' data: https://images.example.com; font-src https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'self'; report-uri /csp-report-endpoint;">
全ページ一律で CSP を設定するなら、.htaccess や Nginx 設定のほうが管理しやすいですし、ページごとに細かく CSP を変える必要がある場合は、PHP などアプリケーションレベルで設定することができます。
メタタグで指定できるのは HTML 文書自体に対する CSP のみで、最初の HTML レスポンスの読み込みより前に適用されないため、初期ロード段階での XSS 防御には不十分な場合があります。
可能であればレスポンスヘッダーで CSP を設定することが推奨されていますが、静的 HTML だけで完結した簡単なページや、サーバー設定を変更できない状況では meta タグによる設定も有効な選択肢です。
WordPress に適用してみる
ローカル環境に WordPress をインストールしてサイトを表示すると、以下のように表示され(オフィシャルテーマ Twenty Twenty-Five を使用)、コンソールにはエラーはありません。
ページのソースを表示すると、以下のように大量のインラインスタイルやインラインスクリプトが出力されているのが確認できます。
注意
以下に示す .htaccess への Content-Security-Policy(CSP)の追加設定は、あくまで CSP の挙動確認やテスト用サイトでの検証を目的とした例です。
運用中の本番サイトでそのまま使用すると、CSP によって多くのスクリプトやスタイルがブロックされ、サイトが正常に表示・動作しなくなる可能性があります。
本番環境に CSP を適用する際は、サイト全体を十分にテストし、必要なリソースを許可する設定を適切に追加してから運用してください。
本番環境では、まず Content-Security-Policy-Report-Only ヘッダーを使用して Report-Only モード でテストすることを強く推奨します。
Report-Only モードでは違反があっても実際のブロックは行われず、ブラウザコンソールやレポート送信先に違反情報が記録されるため、CSP を安全に検証できます。
WordPress のルートディレクトリの .htaccess に以下の CSP を設定します。
# BEGIN WordPress 〜 # END WordPress の外側に記述します。
# CSP の設定を追加
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
</IfModule>
# BEGIN WordPress
# "BEGIN WordPress" から "END WordPress" までのディレクティブ (行) は
# 動的に生成され、WordPress フィルターによってのみ修正が可能です。
# これらのマーカー間にあるディレクティブへのいかなる変更も上書きされてしまいます。
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteBase /csp/
RewriteRule ^index\.php$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /csp/index.php [L]
</IfModule>
# END WordPress
以下のディレクティブを使用しています。
追加した CSP の設定は、以下のような意味を持ちます。
default-src 'self'
- ページに読み込まれる「すべてのリソースのデフォルト許可先」を自身のドメイン('self')に限定。
- インラインスクリプトやインラインスタイルは許可されません。
- script-src、style-src、img-src など、個別ディレクティブが設定されていない場合は、すべてこの default-src が適用されます。
-
逆に、個別ディレクティブを設定した場合は、そちらが優先されます(例えば script-src がある場合は、スクリプトは script-src の指定に従い、default-src では制御されません)。
以下のディレクティブを個別に設定すると、対応するリソースは default-src ではなく、各ディレクティブの設定が優先されます。
- child-src
- connect-src
- font-src
- frame-src
- img-src
- manifest-src
- media-src
- object-src
- prefetch-src
- script-src
- script-src-elem
- script-src-attr
- style-src
- style-src-elem
- style-src-attr
- worker-src
script-src 'self'
- JavaScript の読み込み元を自身のドメイン('self')に限定。
- default-src がある場合でも、script-src の指定があれば script-src が優先されます。
- この例では、'self' だけを指定しているので、省略しても同じです(default-src の 'self' が適用)。
style-src 'self'
- CSS の読み込み元(外部CSSファイルや<style>タグ・style属性)を自身のドメイン('self')に限定。
- default-srcがある場合でも、style-srcの指定があれば style-src が優先されます。
- この例では、'self' だけを指定しているので、省略しても同じです(default-src の 'self' が適用)。
object-src 'none'
- Flashなどの <object> や <embed>、<applet>タグによる外部コンテンツ読み込みを完全に禁止します。
base-uri 'self'
- <base> タグで設定できるベース URL を制御。自分のドメイン('self')だけをベース URL として許可。
- XSSなどで攻撃者に <base> を挿入され、リンクや相対パスの解決先を攻撃者ドメインに変更されるのを防ぎます。
- 'none' とすれば、 <base> タグの使用を禁止できます。
form-action 'self'
- フォーム送信先URLを制御。フォームは自分のドメイン('self')に送信するものだけを許可。
- XSSでフォームのaction属性を書き換えられ、攻撃者サイトへデータが送信されるのを防ぎます。
frame-ancestors 'self'
- ページがどのサイトに iframe で埋め込まれるかを制御し、自サイト('self')からのみ埋め込み可能にします(クリックジャッキング防止)。
上記の CSP の設定を追加後、サイトの表示を確認すると、テーマのデザインが崩れ、コンソールに大量の CSP 違反エラーが出力されているのが確認できます。
エラーメッセージは CSP が正しく機能している証拠ですが、WordPressのデフォルトテーマや多くのプラグインはインラインスクリプト・インラインスタイルを多用しているため、設定した以下のような CSP ポリシーにより、インラインスクリプト・インラインスタイルがすべてブロックされてしまい、結果としてデザインが崩れ、一部の機能が動作しなくなります。
以下はコンソールに出力されたエラーの例です。
以下のエラーは、CSP でインラインコードを禁止している(style-src 'self' や script-src 'self' が指定されているが、'unsafe-inline' や nonce/hash の指定がない)場合に、ブラウザがインラインコードの実行を拒否したことを示しています。
インラインを許可
インラインスクリプトやインラインスタイルの実行を許可するには、以下のような方法があります。
unsafe-inline
以下のように 'unsafe-inline' を追加すると、インラインスクリプト/スタイルの実行が許可されます。
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'self';"
ただし、'unsafe-inline'は XSS 攻撃に対する CSP の効果を大幅に弱めるため、本番環境での常用は推奨されません(現実的な対応)。
nonce
インラインスクリプト・スタイルにランダムな nonce を付け、CSPにも同じ nonce を設定する方法です。これは推奨される方法ですが、実装が大変です。
script-src 'self' 'nonce-ランダム値';
style-src 'self' 'nonce-ランダム値';
<script nonce="ランダム値">インラインスクリプト</script>
<style nonce="ランダム値">インラインスタイル</style>
- 毎リクエストごとにサーバーでnonceを生成し、CSPとインラインコードに同じnonceを挿入する必要があります。
- WordPress テーマやプラグインのコードもすべて nonce 対応させる必要があり、実用的には非常にハードルが高いです。
nonce 指定と unsafe-inline の関係
CSP 仕様では、script-src に nonce-xxxx もしくは sha256-...(ハッシュ値)が含まれると、CSPレベル3以降では unsafe-inline が無効化(無視)されます。
つまり、nonce や hash を使うなら、インラインスクリプトは nonce や hash を付与しない限り実行されません(付与する必要があります)。nonce を導入した時点で「安全なインラインスクリプトだけ許可する」という設計になるので、unsafe-inline は不要かつ機能しなくなります。
WordPress コアや多くのプラグインは対応不可
WordPressや多くのテーマ・プラグインは、wp_add_inline_script() や wp_add_inline_style()、フィルター/アクション経由でのインライン挿入を使っているため、これらの内部的なインライン出力に nonce を付けることは非常に難しいです。
自作テーマや管理下のコードだけなら上記で対応できますが、既存テーマ・プラグイン全体をnonce対応にするのは現実的には困難です。
hash
インラインスクリプト・スタイルの内容に対するSHA-256ハッシュを CSP に書き、ハッシュ一致したインラインコードのみ許可する方法です。
以下はエラーメッセージに表示されているハッシュ値を使って指定する例です。
script-src 'self' 'sha256-h8lXIGuIe7WF5tbIq9thPrAME41HvSpKBiaDrwlZTww=';
コードが変わるとハッシュも変わるため、WordPress 環境のアップデートやプラグイン変更で頻繁に管理が必要になり、限定的に使えますが管理が煩雑になります。
WordPress における現実的な対応
WordPress の標準テーマや多くのプラグインはインラインスクリプト・スタイルを多用しているため、nonce や hash を全面適用するのは現実的に難しく、'unsafe-inline' なしで CSP を完全適用するのは難易度が非常に高いです。
但し、'unsafe-inline' は CSP の最大の武器である「インラインスクリプトの実行禁止」を緩めてしまい、CSP の XSS 防御効果が大きく低下することになるため、以下のようなことに注意します。
不要な外部スクリプト・スタイルの制御を強化する
必ず script-src や style-src で信頼する外部ドメインだけを明示的に許可し、それ以外をブロックする形にします。
script-src 'self' 'unsafe-inline' https://trusted-cdn.example.com;
オブジェクトやフレーム系の禁止でリスク軽減
CSPの object-src 'none'; や frame-ancestors 'self'; などは、インラインスクリプトを許可する代わりにクリックジャッキングや古いプラグインによる攻撃面を減らすのに有効です。
object-src 'none'; frame-ancestors 'self';
入力値検証・エスケープを徹底する
'unsafe-inline' 使用下ではサーバー側での XSS 防御がさらに重要になるため、入力値は常にサニタイズ・エスケープし、特に管理画面のカスタムフィールドやウィジェット、REST API 経由のデータに注意する必要があります。
現実的な対応として、以下のように script-src と style-src に 'unsafe-inline' を追加します。
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
</IfModule>
- default-src 'self';
- script-src 'self' 'unsafe-inline';
- style-src 'self' 'unsafe-inline';
- object-src 'none';
- base-uri 'self';
- form-action 'self';
- frame-ancestors 'self';
サイトの表示を確認すると、インラインスクリプト・インラインスタイルの CSP 違反エラーが消え、ブロックが解除されたのでデザインの崩れは直りますが、まだ、いくつかの CSP 違反エラーが確認できます。
worker-src
以下のエラーは、CSP に worker-src ディレクティブを明示していないため、script-src のポリシーが worker の作成にも適用されていて、それが原因で blob: URL からの Web Worker 生成が拒否されていることを示しています(タイミングによっては表示されない場合もあります)。
- このエラーは主に Web Worker や Service Worker を blob: から生成する際に発生 します。
- デフォルトでは、CSP で worker-src を明示しない場合、script-src が worker-src のフォールバックとして使われます。
- script-src に blob: が含まれていない → blob: から作成しようとする Worker がブロックされる。
WordPressコアやプラグイン、エディター(Gutenbergなど)は、特定の機能を使うタイミングでのみ Worker を生成する場合があるため、同じ画面を開いていても条件によって Worker が起動しないケースがあり、タイミングによってはエラーが表示されないこともあります。
解決策
CSPに worker-src を明示的に追加し、必要なスキーム(通常は 'self' や blob:)を許可します。
worker-src 'self' blob:;
worker-src を設定すると、ブラウザは script-src ではなく worker-src に従って Worker 関連の CSP 評価を行うようになります(worker-src ディレクティブを設けることで、Worker 作成時のポリシー適用を script-src から分離でき、必要な blob: からの Worker 生成を許可できます)。
以下は、この時点での CSP 全体の設定です。
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
</IfModule>
- default-src 'self';
- script-src 'self' 'unsafe-inline';
- style-src 'self' 'unsafe-inline';
- worker-src 'self' blob:;
- object-src 'none';
- base-uri 'self';
- form-action 'self';
- frame-ancestors 'self';
外部リソースを許可
インラインのスクリプトとスタイル、Worder の CSP 違反エラーは消えましたが、まだ、以下の CSP 違反エラーが出力されています。
1つ目のエラー(画像の読み込み拒否)
- img-src が設定されていないため、CSPは default-src('self'だけ許可)を画像読み込みにも適用。
- Gravatarは自身のドメイン外なのでブロックされている状態。
2つ目のエラー(フォントの読み込み拒否)
- font-src が設定されていないため、CSPは default-src('self'のみ許可)をフォントにも適用。
- data URIを使ったインラインフォントがブロックされている状態。
img-src / font-src を個別に設定
CSP に以下を追記します。
img-src 'self' https://secure.gravatar.com;
font-src 'self' data:;
- Gravatar の画像を許可したいので、img-src に Gravatar のドメインを追加。
- data URI フォントを許可したいので、font-src に data: スキームを追加。
以下は、この時点での CSP 全体の設定です。
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' https://secure.gravatar.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
</IfModule>
- default-src 'self';
- script-src 'self' 'unsafe-inline';
- style-src 'self' 'unsafe-inline';
- worker-src 'self' blob:;
- img-src 'self' https://secure.gravatar.com;
- font-src 'self' data:;
- object-src 'none';
- base-uri 'self';
- form-action 'self';
- frame-ancestors 'self';
全てのエラーは消えて、フロントエンド側は正常に表示されます。
外部リソース(reCAPTCHA V3)を許可する例
例えば、Google の reCAPTCHA V3 を実装したコンタクトフォームをサイトに追加した場合、以下のような reCAPTCHA API スクリプトの読み込みがブロックされたことを示す CSP 違反エラーが出力されます。
reCAPTCHA API スクリプト(https://www.google.com/recaptcha/api.js)の読み込みを許可するように、script-src に https://www.google.com を追加する必要があります。
また、reCAPTCHA の仕組みとして、api.js を読み込むと自動的に gstatic.com 上のスクリプトが読み込まれるので、www.gstatic.com も script-src に明示的に追加する必要があります。
script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com;
但し、上記を追加しただけでは、Google reCAPTCHA が挿入する iframe が許可されていないため、以下のようなエラーが表示されます。
iframe(フレーム要素)の読み込み許可先は frame-src で制御します。frame-src を指定していない場合は、default-src の値が iframe の読み込みにも適用されるので、現在は iframe も 'self' からしか読み込めず、Google reCAPTCHA が挿入する iframe(https://www.google.com/recaptcha/ など)を拒否してしまっています。
reCAPTCHA を正しく動作させるには frame-src も指定します。
frame-src 'self' https://www.google.com https://www.gstatic.com;
以下は、上記(reCAPTCHA V3 のリソースを許可)を反映した CSP 全体の設定例です。
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; frame-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline'; worker-src 'self' blob:; img-src 'self' https://secure.gravatar.com; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
</IfModule>
- default-src 'self';
- script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com;
- frame-src 'self' https://www.google.com https://www.gstatic.com;
- style-src 'self' 'unsafe-inline';
- worker-src 'self' blob:;
- img-src 'self' https://secure.gravatar.com;
- font-src 'self' data:;
- object-src 'none';
- base-uri 'self';
- form-action 'self';
- frame-ancestors 'self';
Googleの多くのサービスは静的コンテンツ(JS、フォント、画像など)を gstatic.com ドメインから配信しており、Google Fonts や reCAPTCHA でも gstatic が必要になります(gstatic は Google の CDN 的役割を果たしていて、多くの Google サービスが必要とします)。
Google Analytics や Google Maps などの Google サービスを利用する場合も、script-src や img-src など CSP ディレクティブで gstatic.com や google.com を明示的に許可する必要があります。
編集画面
フロントエンドは問題なく表示されるようになりましたが、投稿や固定ページの編集画面、サイトエディターを開くと以下のように「このコンテンツはブロックされました。サイト所有者に連絡して問題を解決してください。」と表示され、機能していません。
編集画面でコンテンツがブロックされているのは、Gutenberg(ブロックエディタ)の動作に必要なリソースが CSP でブロックされていることが原因です。
コンソールには以下のエラーが出力されています。
1つ目のエラー(data:スキームの読み込み拒否)
Gutenberg や WordPress 管理画面はデータ URI 形式の画像(data:スキーム)を一部使用します。
img-src に data: を追加します。
img-src 'self' https://secure.gravatar.com data:;
2つ目のエラー(blob: スキームの読み込み拒否)
Gutenberg は内部的に iframe を利用しますが、WordPress 管理画面では iframe の src に blob: スキームが使われます。frame-src に blob: を追加します。
frame-src 'self' https://www.google.com https://www.gstatic.com blob:;
3つ目のエラー(Uncaught TypeError)
3つ目の Uncaught TypeError は frame-src や img-src の CSP 違反によって、Gutenberg 内部の JS が想定通りに iframe を生成できず、結果としてJS エラーが発生しているので、上記 CSP の緩和で自然と解消されます。
以下は CSP 全体の設定例です。
<IfModule mod_headers.c>
Header set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; frame-src 'self' https://www.google.com https://www.gstatic.com blob:; style-src 'self' 'unsafe-inline'; img-src 'self' https://secure.gravatar.com data:; font-src 'self' data:; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self';"
</IfModule>
- default-src 'self';
- script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com;
- frame-src 'self' https://www.google.com https://www.gstatic.com blob:;
- style-src 'self' 'unsafe-inline';
- img-src 'self' https://secure.gravatar.com data:;
- font-src 'self' data:;
- worker-src 'self' blob:;
- object-src 'none';
- base-uri 'self';
- form-action 'self';
- frame-ancestors 'self';
管理画面用の CSP を分ける
管理画面とフロントでは読み込むリソースが異なるため、必要に応じてそれぞれに最適な CSP を設定できます。管理画面だけに必要な許可(blob: や data: など)を含めることで、投稿編集やサイトエディターなどの機能を正常に動作させながら、フロント側ではより厳格な CSP を適用できます。
.htaccess の CSP 設定は削除する
サーバー側の .htaccess に CSP を記述してしまうと、管理画面とフロントで同じポリシーしか適用できません。管理画面とフロントで異なる CSP を出し分けたい場合は、.htaccess の CSP 設定を削除して、PHP 側で制御する方法に切り替えます。
functions.php に設定を追加
テーマの functions.php に以下のようなコードを追加します。WordPress が HTTP レスポンスヘッダーを送信するタイミングである send_headers アクションを使って、header() 関数で CSP を出力します。管理画面かどうかを判定して、CSP を切り替えられるようになっています。
add_action('send_headers', function () {
if (is_admin() && strpos($_SERVER['PHP_SELF'], '/wp-admin/') !== false) {
// 管理画面用 CSP
header("Content-Security-Policy: default-src 'self'; "
. "script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' https://secure.gravatar.com data:; " // 管理画面用では data: 追加
. "font-src 'self' data:; "
. "worker-src 'self' blob:;"
. "frame-src 'self' https://www.google.com https://www.gstatic.com blob:; " // 管理画面用では blob: 追加
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self';");
} else {
// フロント用 CSP
header("Content-Security-Policy: default-src 'self'; "
. "script-src 'self' 'unsafe-inline' https://www.google.com https://www.gstatic.com; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' https://secure.gravatar.com; "
. "font-src 'self' data:; "
. "worker-src 'self' blob:;"
. "frame-src 'self' https://www.google.com https://www.gstatic.com; "
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self';");
}
});
is_admin() だけで管理画面かどうかを判定すると、REST API や admin-ajax.php のリクエスト時にも true になってしまいます。
そのため、strpos($_SERVER['PHP_SELF'], '/wp-admin/') !== false で「リクエスト先が /wp-admin/ 配下の PHP ファイルであるか」を確認することで、実際に管理画面 HTML を返すリクエストのみをターゲットにできます。
また、以下のように投稿編集画面・新規投稿画面・サイトエディター(FSE編集画面)用に分岐することもできますが、分岐を細かくしすぎるとメンテナンス性や把握性が下がる可能性があります。
if (is_admin() && strpos($_SERVER['PHP_SELF'], '/wp-admin/') !== false) {
$request_uri = $_SERVER['REQUEST_URI'];
if (strpos($request_uri, '/wp-admin/post.php') !== false ||
strpos($request_uri, '/wp-admin/post-new.php') !== false ||
strpos($request_uri, '/wp-admin/site-editor.php') !== false) {
// 投稿編集・新規・FSE編集画面用 CSP
} else {
// その他の管理画面用 CSP
}
} else {
// フロント用 CSP
}
script-src に unsafe-inline を使わない
WordPress における現実的な対応として、script-src と style-src に 'unsafe-inline' を追加しましたが、Content-Security-Policy(CSP)を設定する際、インラインスクリプトは unsafe-inline を避け、ハッシュや nonce を使うのが推奨されています。
しかし、WordPress ではテーマやプラグインが自動的にインラインスクリプトを生成するケースが多く、これらの内容は WordPress やプラグインの更新、設定変更などによって簡単に変わってしまいます。
そのため、ハッシュ方式を使う場合は、少しでもスクリプトに変更があるとCSPに設定しているハッシュが一致しなくなり、該当するスクリプトがブロックされてページの一部または全体が正常に動作しなくなるリスクがあります。
特に WordPress は自動アップデートを含め、頻繁にファイルや出力内容が変化するため、CSP のハッシュを都度メンテナンスできる体制がない場合、運用負荷が大きくなる点に注意が必要です。
以下に紹介する CSP の例は、script-src では unsafe-inline を避け、style-src のみ unsafe-inline を許可する構成を試したものですが、運用時にはこうしたCSPの保守性リスクを考慮する必要があります。
script-src から unsafe-inline を外し、必要なインラインスクリプトには CSP ハッシュを設定します。
script-src から unsafe-inline を外すと、例えば、コンソールに以下のような違反情報にハッシュが表示されるので、それらを script-src にそのまま指定して、特定のインラインスクリプトだけ許可します。
以下のように script-src にハッシュを追加すれば、インスクリプトは許可されます。
header("Content-Security-Policy: default-src 'self'; "
// script-src にハッシュを追加
. "script-src 'self' 'sha256-h8lXIGuIe7WF5tbIq9thPrAME41HvSpKBiaDrwlZTww=' 'sha256-jshOvw6Hei5P7OyeTUBI5SUCi4GTANfXZu5ubFdT0Xg=' 'sha256-tXLJPXVDN27lgO0FxEe5u5gR/040zmcZMa1raocz4Ho='; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' https://secure.gravatar.com data:; "
. "font-src 'self' data:; "
. "worker-src 'self' blob:;"
. "frame-src 'self' https://www.google.com https://www.gstatic.com blob:; "
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self';");
独自に読み込んでいる JavaScript に nonce を設定
この例では、お問い合わせの確認ページで Google reCAPTCHA v3 用スクリプトを読み込んでいます。そのため、このスクリプトを使用するページでは以下のようなエラーが表示されます。
Google reCAPTCHA v3 用スクリプトは、以下のように wp_enqueue_scripts で を読み込んでいるので、このスクリプトに nonce を設定します。
また、wp_add_inline_script() を使って、サイトキーをインラインスクリプトでグローバル変数として出力していますが、こちらは nonce を設定できないので、後で CSP ハッシュを追加します。
function my_enqueue_contact_script_style() {
// reCAPTCHA v3 用スクリプトの読み込み
if (is_page('confirm')) {
// サイトキー
$site_key = defined('RECAPTCHA_V3_SITE_KEY') ? RECAPTCHA_V3_SITE_KEY : '';
// Google の reCAPTCHA v3 API を読み込む( ?render=サイトキー 付き)
wp_enqueue_script(
'google-recaptcha-v3', // ハンドル名(id 属性は google-recaptcha-v3-js になる)
"https://www.google.com/recaptcha/api.js?render={$site_key}",
array(),
null,
true
);
// reCAPTCHA サイトキーを参照できるようにインラインスクリプトでグローバル変数として出力
wp_add_inline_script(
'recaptcha-v3-handler-js',
'window.recaptchaV3SiteKey = "' . esc_js($site_key) . '";',
'before'
);
}
....
}
add_action('wp_enqueue_scripts', 'my_enqueue_contact_script_style');
ランダムな nonce を生成する関数を用意して、reCAPTCHA v3 用スクリプトに nonce を設定します。
wp_script_attributes や send_headers が実行される前に nonce を生成してグローバル変数にセットするように、init や wp_loaded などのフックで nonce を生成してグローバル変数に保持しておきます。
wp_script_attributes フィルターを使うと script タグに属性を追加することができます。
WordPressは script タグに自動で id属性として {ハンドル名}-js を付けるので、wp_enqueue_script('google-recaptcha-v3', ...); の場合は id が google-recaptcha-v3-js になります。
// グローバルで保持する nonce を生成する関数
function generate_csp_nonce() {
// 1リクエスト内で1度だけ生成し、同じ値を返すよう static で保持
static $nonce = '';
if ($nonce === '') {
$nonce = base64_encode(random_bytes(16));
}
return $nonce;
}
// nonce をできるだけ早い段階で生成して保持
add_action('init', function() {
global $nonce;
$nonce = generate_csp_nonce();
});
// reCAPTCHA v3 用スクリプトに nonce を設定
function add_nonce_to_recaptcha_script($attributes) {
// script タグの id 属性の値で対象のスクリプトを特定して属性を追加
if (isset($attributes['id']) && $attributes['id'] === 'google-recaptcha-v3-js') {
// init で生成した $nonce(グローバル変数)を参照
global $nonce;
// scrip tタグに nonce 属性を追加(nonce=$nonce)
$attributes['nonce'] = $nonce;
}
return $attributes;
}
add_filter('wp_script_attributes', 'add_nonce_to_recaptcha_script', 10, 1);
関数 generate_csp_nonce() は、同じリクエスト中で一度だけランダムな値(nonce)を生成し、そのリクエスト中はずっと同じ nonce を返すという目的で作られています。
PHPの static 変数は、関数の外(グローバル)に宣言しなくても、その関数内だけで値を保持できる変数です。通常のローカル変数は関数が呼ばれるたびに初期化されますが、static 変数は1回目の呼び出しで値を設定すると、その後も同じ値を記憶してくれます。
- 最初にこの関数が呼ばれたときは $nonce は空文字列('')になります。
- そのときだけ if ($nonce === '') が真になり、ランダムな値を生成して $nonce に代入します。
- 2回目以降の呼び出しでは $nonce にはすでに値が入っているため、$nonce === '' は偽となり、同じ nonce が返されます。
static $nonce = ''; は「関数の定義時」ではなく「関数のスコープで最初に呼ばれたときだけ」初期化されるため、同じリクエスト中で何度この関数を呼んでも、再度初期化されることはありません。
CSPの nonce はページ内の複数のスクリプトで共通の値を使う必要があります。
リクエストごとに一度だけ生成し、どこから呼ばれても同じ値を返すことで、ページ内のスクリプトタグや CS Pヘッダーで同じ nonce を利用でき、かつページをリロードすると毎回異なる nonce になる(リクエスト単位でランダム化)という仕組みが簡単に実現できます。
init で生成した nonce を使って、script-src に 'nonce-{$nonce}' を追加します。
add_action('send_headers', function () {
global $nonce; // init で生成した nonce を使い、CSPヘッダーに nonce を含めて出力
header("Content-Security-Policy: default-src 'self'; "
. "script-src 'self' 'nonce-{$nonce}' 'sha256-h8lXIGuIe7WF5tbIq9thPrAME41HvSpKBiaDrwlZTww=' 'sha256-jshOvw6Hei5P7OyeTUBI5SUCi4GTANfXZu5ubFdT0Xg=' 'sha256-tXLJPXVDN27lgO0FxEe5u5gR/040zmcZMa1raocz4Ho='; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' https://secure.gravatar.com data:; "
. "font-src 'self' data:; "
. "worker-src 'self' blob:;"
. "frame-src 'self' https://www.google.com https://www.gstatic.com blob:; "
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self';");
});
reCAPTCHA 用スクリプトは許可できましたが、コンソールを確認すると、今度はサイトキーを出力する独自に定義したインラインスクリプトに対して、以下のようなエラーが表示されます。
wp_add_inline_script() を使って出力するインラインスクリプトには nonce を付与できないので、インラインスクリプトを許可するようにハッシュを追加します。
コンソールに出力されたエラーに表示されているハッシュを確認して script-src に追加します。
add_action('send_headers', function () {
global $nonce;
header("Content-Security-Policy: default-src 'self'; "
// ハッシュを追加
. "script-src 'self' 'nonce-{$nonce}' 'sha256-h8lXIGuIe7WF5tbIq9thPrAME41HvSpKBiaDrwlZTww=' 'sha256-jshOvw6Hei5P7OyeTUBI5SUCi4GTANfXZu5ubFdT0Xg=' 'sha256-tXLJPXVDN27lgO0FxEe5u5gR/040zmcZMa1raocz4Ho=' 'sha256-jwTGS/uM3Y8EZmT3KOyIXu5g80UoJIV/3xty1gYA+5U='; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' https://secure.gravatar.com data:; "
. "font-src 'self' data:; "
. "worker-src 'self' blob:;"
. "frame-src 'self' https://www.google.com https://www.gstatic.com blob:; "
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self';");
});
これでエラーはなくなりましたが、実際にコンタクトフォームを送信してみると、送信は成功しますが、コンソールに一瞬エラーが表示されます。
但し、フォーム送信に伴い、すぐに消えてしまうのでコンソールで確認するのは難しいですが、Report-Only モードでレポートを保存するか、レポートビューアーを作成しておくと確認することができます。
レポートビューアーで確認すると connect-src で https://www.google.com への接続で違反になっていたので、CSP に "connect-src 'self' https://www.google.com; " を追加する必要がありました。
add_action('send_headers', function () {
global $nonce;
header("Content-Security-Policy: default-src 'self'; "
. "script-src 'self' 'nonce-{$nonce}' 'sha256-h8lXIGuIe7WF5tbIq9thPrAME41HvSpKBiaDrwlZTww=' 'sha256-jshOvw6Hei5P7OyeTUBI5SUCi4GTANfXZu5ubFdT0Xg=' 'sha256-tXLJPXVDN27lgO0FxEe5u5gR/040zmcZMa1raocz4Ho=' 'sha256-jwTGS/uM3Y8EZmT3KOyIXu5g80UoJIV/3xty1gYA+5U='; "
. "style-src 'self' 'unsafe-inline'; "
. "img-src 'self' https://secure.gravatar.com data:; "
. "font-src 'self' data:; "
. "worker-src 'self' blob:;"
. "frame-src 'self' https://www.google.com https://www.gstatic.com blob:; "
. "connect-src 'self' https://www.google.com; " // connect-src を追加
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self';");
});
Report-Only(レポート収集用設定)
Content-Security-Policy-Report-Only は、CSP の Report-Only モード(テストモード)で、「実際にはブロックせず、CSP 違反があったらブラウザが指定した URL へレポートを送る」機能です。
CSP をサイトに厳格に適用する前に、ポリシーを導入してもサイトや機能に問題が起きないかを確認する目的で使用します。ブラウザは仕様として、ポリシー違反があった場合にコンソールに [Report Only] と付けてどのディレクティブが違反したかどのリソースが問題かなどを自動的に開発者ツールに表示します。
目的
- 実際にブロックせずに違反を検知
- 通常の Content-Security-Policy ヘッダーは、違反したリソースの読み込みをブラウザが即座にブロックします。
- 一方 Content-Security-Policy-Report-Only は違反を報告するだけで、リソース読み込み自体は許可されます。
- 既存サイトにCSPを導入する際のトラブル回避
- 本番環境でCSPを適用すると、思わぬ箇所でJavaScriptやCSSが動作しなくなる可能性があります。
- Report-Onlyでまずレポートを収集し、どんなリソースが読み込まれているかを確認してから、本番用のCSPに移行できます。
- ポリシー改善に役立つ情報を収集
- サイト訪問者のブラウザから送られてくるCSPレポートをもとに、どのディレクティブを緩める・強める必要があるかを判断できます。
基本的な使い方
例えば、サーバーから以下のように HTTP レスポンスヘッダーを送信します。
<IfModule mod_headers.c>
Header set Content-Security-Policy-Report-Only "default-src 'self'; report-uri /csp-report-endpoint;"
</IfModule>
- default-src 'self'
- デフォルトですべてのリソースは自ドメインからの読み込みのみ許可。
- report-uri /csp-report-endpoint
- 違反が発生した場合に、ブラウザが /csp-report-endpoint にレポートをPOST送信します。
PHPでの設定例
WordPress や PHP アプリケーションでは、以下のようにヘッダーを送信します。
add_action('send_headers', function () {
header("Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report-endpoint;");
});
レポート送信先の指定方法
現在、CSP レポートの送信先は2種類指定できます。
- report-uri
- 古いブラウザも対応しており、違反内容をURLに送信します。仕様上は廃止予定。
- report-to
- Report-To ヘッダーと組み合わせる仕様で、より柔軟に複数エンドポイントなどを指定可能。レポートの送信先は、Report-To ヘッダーで「グループ名」など含めて事前に構成します。
両方を同時に使えば、幅広いブラウザでレポートを収集できます。
<IfModule mod_headers.c>
# Report-To ヘッダーを設定
Header set Report-To "{ \"group\": \"csp-endpoint\", \"max_age\": 10886400, \"endpoints\": [ { \"url\": \"https://YOUR_DOMAIN/csp-report-endpoint\" } ] }"
# Content-Security-Policy-Report-Only に report-to を指定
Header set Content-Security-Policy-Report-Only "default-src 'self'; report-uri https://YOUR_DOMAIN/csp-report-endpoint; report-to csp-endpoint;"
Report-To ヘッダーで定義した「グループ名」(ここでは csp-endpoint)を CSP で report-to ディレクティブに指定します。
$report_url = home_url('https://example.com/csp-report.php'); // エンドポイントの URL
// Report-To ヘッダーは JSON 形式で送る
header('Report-To: ' . json_encode([
'group' => 'csp-endpoint', // group は任意の名前(ここでは csp-endpoint)
'max_age' => 10886400, // 有効期間(秒)
'endpoints' => [['url' => $report_url]], // レポートを送るURL
]));
header("Content-Security-Policy-Report-Only: default-src 'self'; report-uri {$report_url}; report-to csp-endpoint;");
以下は MDN CSP: report-uri からの引用です。
非推奨: この機能は非推奨になりました。まだ対応しているブラウザーがあるかもしれませんが、すでに関連するウェブ標準から削除されているか、削除の手続き中であるか、互換性のためだけに残されている可能性があります。使用を避け、できれば既存のコードは更新してください。
警告: report-to ディレクティブは、report-uri ディレクティブを置き換えるためのもので、report-to に対応するブラウザーでは、report-uri ディレクティブは無視されます。
但し、report-to の対応が進むまでは、両方のヘッダー指定することができます。
ブラウザ対応状況:can i use : HTTP header: Content-Security-Policy: report-to
Report-Only モード設定サンプル
WordPress のテーマで Report-Only モードを設定する例です。
任意のテーマに、レポート受信用エンドポイントとレポートビューアー作成し、CSP 違反のレポートを簡単に確認できるようにします。
以下はテーマ Twenty Twenty-Five の場合の構成例です。
但し、以下はあくまでテスト用です。実運用時は IP 制限や認証、ログローテーションなどが必要です。
エンドポイントを用意
CSP 違反レポートを受け取る URL(エンドポイント)が必要なので、レポート受信用の PHP スクリプトを用意します。テーマディレクトリに以下のファイルを作成します:
/wp-content/themes/your-theme/csp-report.php
このスクリプトは、CSP 違反レポートを受信して記録するエンドポイントです。ブラウザから送信される CSP 違反の JSON レポートをサーバーに保存します。
レポートは JSON Lines 形式(1行に1レポートの JSON)で保存されるため、後から解析やビューアーでの参照に便利です。
<?php
// タイムゾーンを日本時間に設定
date_default_timezone_set('Asia/Tokyo');
// ブラウザから送られてきたリクエストボディ(JSONデータ)を取得
$input = file_get_contents("php://input");
// レポートを保存するファイルパスを設定
$reportFile = __DIR__ . '/csp-reports/reports.json';
// 保存先ディレクトリが存在しない場合は作成する(パーミッション700)
if (!file_exists(dirname($reportFile))) {
mkdir(dirname($reportFile), 0700, true);
}
// 入力データが空でない場合のみ処理
if (!empty($input)) {
// 入力をJSONとしてデコード
$decoded = json_decode($input, true);
// デコードが成功し、かつ何らかの内容が含まれていれば処理を続行
if (json_last_error() === JSON_ERROR_NONE && !empty($decoded)) {
// レポートを配列にまとめる
$entry = [
'time' => microtime(true), // レポート受信時間(高精度)
'raw' => $decoded, // 元のJSONをそのまま保存
];
// report-uri形式の場合:{ "csp-report": { ... } } という構造
if (isset($decoded['csp-report'])) {
$entry['type'] = 'report-uri'; // タイプを記録
$entry['report'] = $decoded['csp-report']; // 実際のレポート内容
}
// report-to形式の場合:[{ type: "csp-violation", body: {...}, ... }] という構造
elseif (isset($decoded[0]['type']) && $decoded[0]['type'] === 'csp-violation') {
$entry['type'] = 'report-to'; // タイプを記録
$entry['report'] = $decoded; // 実際のレポート内容(配列全体を保存)
}
// JSON Lines形式(1行1レポート)でファイルに追記する
file_put_contents($reportFile, json_encode($entry) . PHP_EOL, FILE_APPEND | LOCK_EX);
}
}
// JSONレスポンスを返却して完了を通知
header("Content-Type: application/json");
echo json_encode(["status" => "ok"]);
処理の流れ
- POSTリクエストのボディを取得。
- 保存先ディレクトリが存在しない場合は作成
- JSONとしてデコード。
- レポート内容を検証し、ディレクトリを作成してレポートファイルに追記。
- レスポンスとして JSON を返却。
レポートを形式ごとに保存
このスクリプトは report-uri 形式 と report-to 形式 の両方をサポートしています。
if (isset($decoded['csp-report'])) {
// report-uri形式のレポート
$entry['type'] = 'report-uri'; // タイプを「report-uri」と記録
$entry['report'] = $decoded['csp-report']; // 実際の内容は1つの連想配列
} elseif (isset($decoded[0]['type']) && $decoded[0]['type'] === 'csp-violation') {
// report-to形式のレポート
$entry['type'] = 'report-to'; // タイプを「report-to」と記録
$entry['report'] = $decoded; // 実際の内容は配列全体
}
受け取った JSON の構造を見て、どちらの形式かを判断して保存しています。
- report-uri 形式なら type=report-uri、内容は $decoded['csp-report']
- report-to 形式なら type=report-to、内容は $decoded の配列
後で作成するビューアーでは、保存されたJSON Linesファイル(reports.json)を1行ずつ読んで、$entry['type'] の値を見て以下のような分岐を行います。
- report-uri なら「単一の連想配列」として処理
- report-to なら「配列の中の body 要素」として処理
レポート収集用 CSP ヘッダーを設定
WordPress では、send_headers フックを使って簡単に Content-Security-Policy の Report-Only ヘッダーを送信できます。
以下のようにコードを追加することで、ブラウザから CSP 違反レポートを収集できます。
script-src などのディレクティブとその設定は、サイトの使用状況に合わせて自由に変更可能です。
また、report-uri と report-to の両方のディレクティブを指定することで、古いブラウザと新しいブラウザの両方に対応し、先ほど作成したレポート受信用エンドポイントへ違反レポートを送信できます。
※ CSP ヘッダーの report-to ディレクティブで指定する名前と Report-To ヘッダーの group は必ず一致させる必要があります。
add_action('send_headers', function () {
$report_url = home_url('/wp-content/themes/twentytwentyfive/csp-report.php');
// Report-To ヘッダーを送信(グループ名: csp-endpoint)
header('Report-To: ' . json_encode([
'group' => 'csp-endpoint', // report-to ディレクティブで参照するグループ名
'max_age' => 10886400, // エンドポイント情報の有効期間(秒)
'endpoints' => [['url' => $report_url]], // レポート送信先
]));
// Content-Security-Policy-Report-Only ヘッダーを送信
// script-src などは必要に応じて変更し、report-uri / report-to の両方を指定
header("Content-Security-Policy-Report-Only: default-src 'self'; "
. "script-src 'self'; "
. "style-src 'self'; "
. "img-src 'self'; "
. "font-src 'self'; "
. "worker-src 'self'; "
. "frame-src 'self'; "
. "object-src 'none'; "
. "base-uri 'self'; "
. "form-action 'self'; "
. "frame-ancestors 'self'; "
. "report-uri {$report_url}; "
. "report-to csp-endpoint;"
);
});
注意
ローカル環境(http://)で動作確認する場合、ブラウザが report-to に対応していても、HTTPS でないとレポートが送信されないことがあります。
その場合は、CSPヘッダー中の以下の行を一時的にコメントアウトし、代わりに report-uri のみでレポートを受け取れるようにすることができます。
レポートビューアー
以下のビューアーはサーバーに保存された CSP 違反レポート(JSON Lines 形式の reports.json)を読み込み、テーブル形式で表示します。
- report-uri 形式(単一レポート)と report-to 形式(複数レポート)を自動判定して処理します。
- Document URI・Violated Directiveでのフィルタリング機能があります。
- 100件ごとにページ分割して表示します。
- .htaccess の環境変数 VIEWER_PASSWORD による簡易認証を搭載しています。
以下がコード全体の流れです。
- 認証チェック(環境変数 VIEWER_PASSWORD と一致するか)
- レポートファイルの存在確認と読み込み
- ページネーション(100件単位)
- レポート形式(report-uri/report-to)を判定
- HTML 出力開始
- レポート形式と検索フォームを表示
- report-uri / report-to 形式に応じたレポート出力
- ページネーションリンクの生成
- レポート表示用の共通出力関数
<?php
// レポートファイルのパスを指定
$reportFile = __DIR__ . '/csp-reports/reports.json';
// 認証:.htaccess などで SetEnv VIEWER_PASSWORD 'xxxx' と設定している場合に
// その値と一致するパスワード(xxxx)が入力されていないと401エラーを返す
$password = getenv('VIEWER_PASSWORD');
if ($password && (!isset($_SERVER['PHP_AUTH_PW']) || $_SERVER['PHP_AUTH_PW'] !== $password)) {
header('WWW-Authenticate: Basic realm="CSP Viewer"');
header('HTTP/1.0 401 Unauthorized');
die('Unauthorized');
}
// レポートファイルがなければ終了
if (!file_exists($reportFile)) {
die('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>No Reports</title></head><body><p>No reports found.</p></body></html>');
}
// 1行1レポートとして全行を配列で読み込み
$lines = file($reportFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// 新しいレポートが最後に追記される形式なので逆順にして新しいものから表示
$lines = array_reverse($lines);
// フィルタ用パラメータ(GETパラメータからフィルタ用の文字列を取得)
$searchDocumentUri = isset($_GET['search_document_uri']) ? trim($_GET['search_document_uri']) : '';
$searchViolatedDirective = isset($_GET['search_violated_directive']) ? trim($_GET['search_violated_directive']) : '';
// ページネーション設定
$perPage = 100; // 1ページの表示件数
$totalReports = count($lines);
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$offset = ($page - 1) * $perPage;
$lines = array_slice($lines, $offset, $perPage); // 該当ページ分のレポートだけに絞る
// 最新のレポートから形式(report-uri/report-to)を判定
$currentType = 'Unknown';
if (!empty($lines)) {
$latest = json_decode($lines[0], true);
if ($latest && isset($latest['type'])) {
$currentType = $latest['type'];
}
}
// HTML出力開始
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSP Reports Viewer</title>
<style>
body {
font-family: sans-serif;
margin: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: 0.5rem;
border: 1px solid #ccc;
vertical-align: top;
}
td {
overflow-wrap: break-word;
word-break: break-all;
}
th {
background-color: #eee;
}
form input[type="text"] {
width: 300px;
}
</style>
</head>
<body>
<h1>CSP Reports</h1>
<!-- 現在のレポート形式を表示 -->
<p>Current report type: <strong style="color:blue;"><?= htmlspecialchars($currentType) ?></strong></p>
<!-- 検索フォームを出力 -->
<form method="get" style="margin-bottom:1em; display:flex; gap:1em; flex-wrap:wrap;">
<label>Filter by Document URI: <input type="text" name="search_document_uri" value="<?= htmlspecialchars($searchDocumentUri) ?>"></label>
<label>Filter by Violated Directive: <input type="text" name="search_violated_directive" value="<?= htmlspecialchars($searchViolatedDirective) ?>"></label>
<div>
<button type="submit">Filter</button>
<a href="<?= htmlspecialchars($_SERVER['PHP_SELF']) ?>" style="margin-left:10px; border: 1px solid #999; padding: 3px 10px; text-decoration: none;">Clear</a>
</div>
</form>
<!-- レポート表示テーブル-->
<table>
<tr>
<th>Time</th>
<th>Document URI</th>
<th>Violated Directive</th>
<th>Blocked URI</th>
<th>Referrer</th>
<th>Status</th>
<th>Script Sample</th>
<th>Line Number</th>
</tr>
<?php
// レポート出力(レポートを1行ずつ表示)
foreach ($lines as $line) {
$entry = json_decode($line, true);
if (!$entry || empty($entry['report'])) continue;
// 時刻をフォーマット(レポートに記録された microtime を「日本時間の見やすい形式」で表示)
// $entry['time'] は microtime(true) で保存されたUnixタイムスタンプ("1751349465.064782" のように秒+マイクロ秒の形式)
// createFromFormat('U.u', ...) で「Unixエポック秒.マイクロ秒」形式を DateTime オブジェクトに変換
$dt = DateTime::createFromFormat('U.u', sprintf('%.6F', $entry['time']));
if ($dt) {
// タイムゾーンを日本時間(Asia/Tokyo)に設定して、日本時間で表示できるように
$dt->setTimezone(new DateTimeZone('Asia/Tokyo'));
// 「YYYY-MM-DD HH:MM:SS.ミリ秒」形式の文字列にフォーマットして、表示用に
$time = $dt->format('Y-m-d H:i:s.v');
} else {
// 変換に失敗した場合は '-' を表示。
$time = '-';
}
// report-uri 形式
if ($entry['type'] === 'report-uri') {
outputRow($entry['report'], $time, 'report-uri', $searchDocumentUri, $searchViolatedDirective);
}
// report-to形式(複数レポートに対応)
elseif ($entry['type'] === 'report-to' && is_array($entry['report'])) {
foreach ($entry['report'] as $rep) {
if (!isset($rep['body'])) continue;
outputRow($rep['body'], $time, 'report-to', $searchDocumentUri, $searchViolatedDirective);
}
}
}
?>
</table>
<?php
// ページネーションリンクを表示
$totalPages = max(1, ceil($totalReports / $perPage));
echo '<div style="margin-top:1em;">';
for ($i = 1; $i <= $totalPages; $i++) {
if ($i == $page) {
echo "<strong>$i</strong> ";
} else {
$url = htmlspecialchars($_SERVER['PHP_SELF']) . '?page=' . $i;
echo "<a href=\"$url\">$i</a> ";
}
}
echo '</div>';
?>
</body>
</html>
<?php
/**
* レポート1件をテーブルの行として出力する共通関数
*
* - report-uri形式の場合:
* レポート本体は {"csp-report": {...}} の構造なので、
* document-uri や violated-directive などのキーが直接入っている。
*
* - report-to形式の場合:
* レポート本体は [{ type: "csp-violation", body: {...}, ... }] の配列構造で、
* 各レポートの body 内に documentURL や effectiveDirective などのキーが含まれる。
*
* そのため、形式によって参照するキー名が異なるので、例えば、
* report-uriは document-uri / violated-directive、
* report-toは documentURL / effectiveDirective を使っている。
*/
function outputRow($report, $time, $type, $searchDocumentUri, $searchViolatedDirective) {
$documentUri = $type === 'report-uri' ? $report['document-uri'] ?? '' : $report['documentURL'] ?? '';
$violatedDirective = $type === 'report-uri' ? $report['violated-directive'] ?? '' : $report['effectiveDirective'] ?? '';
$blockedUri = htmlspecialchars($type === 'report-uri' ? $report['blocked-uri'] ?? '-' : $report['blockedURL'] ?? '-');
$referrer = htmlspecialchars($report['referrer'] ?? '-');
$statusCode = htmlspecialchars($type === 'report-uri' ? $report['status-code'] ?? '-' : $report['statusCode'] ?? '-');
$scriptSample = htmlspecialchars($type === 'report-uri' ? $report['script-sample'] ?? '-' : $report['sample'] ?? '-');
$lineNumber = htmlspecialchars($type === 'report-uri' ? $report['line-number'] ?? '-' : $report['lineNumber'] ?? '-');
// 検索条件に合わない場合はスキップ
if ($searchDocumentUri !== '' && stripos($documentUri, $searchDocumentUri) === false) return;
if ($searchViolatedDirective !== '' && stripos($violatedDirective, $searchViolatedDirective) === false) return;
// テーブル行を出力
echo '<tr>';
echo "<td>{$time}</td>";
echo "<td>" . htmlspecialchars($documentUri) . "</td>";
echo "<td>" . htmlspecialchars($violatedDirective) . "</td>";
echo "<td>{$blockedUri}</td>";
echo "<td>{$referrer}</td>";
echo "<td>{$statusCode}</td>";
echo "<td style='max-width:300px; overflow-wrap:anywhere; font-family:monospace;'>{$scriptSample}</td>";
echo "<td>{$lineNumber}</td>";
echo '</tr>';
}
環境変数による簡易認証
この認証は HTTP Basic 認証を使ったシンプルなものです。以下が使い方です。
サーバー環境に VIEWER_PASSWORD という環境変数を設定します。
例えば Apache の .htaccess の場合、以下のように記述することで環境変数を設定できます。'xxxxx' には実際に使用するパスワードを設定します。
SetEnv VIEWER_PASSWORD 'xxxxx'
環境変数 VIEWER_PASSWORD が設定されていない場合(環境変数が空や未定義の場合)は認証をスキップする仕組みなので、ローカル開発時などに認証を省略したいときは、環境変数 VIEWER_PASSWORD を設定しなければ、認証は行われません。
環境変数 VIEWER_PASSWORD を設定すると、ビューアーにアクセスする際に以下のような HTTP Basic 認証が表示されるので、設定したパスワード xxxx を入力すると、ビューアーが表示されます。ユーザー名欄は何を入力してもOKです(空でも問題ありません)。
※ サーバー側で別途 Basic 認証(.htpasswdなど)を設定している場合は、そちらの認証が先に反応し、そちらのユーザー名/パスワードが通ればアクセス可能になります。
CSV ダウンロード対応版
以下は、表示している CSP レポートをそのまま CSV ファイルとしてダウンロードできる簡単なエクスポート機能を付けたバージョンです。
ダウンロードリンクをクリックすると、現在のページに ?download=csv が付与されて GET リクエストが送信されます。
ページにアクセスした際に、GETパラメータ download=csv が含まれていれば、generate_csv() を呼び出し、CSV を出力し、header() で適切な Content-Type やダウンロード用のヘッダーを返すことで、ブラウザにCSVファイルとしてダウンロードを促します。
<?php
if (isset($_GET['download']) && $_GET['download'] === 'csv') {
generate_csv(); // CSV生成用関数を呼び出す
exit; // CSVファイルを返したらそれ以上の処理は不要なので終了
}
$reportFile = __DIR__ . '/csp-reports/reports.json';
$password = getenv('VIEWER_PASSWORD');
if ($password && (!isset($_SERVER['PHP_AUTH_PW']) || $_SERVER['PHP_AUTH_PW'] !== $password)) {
header('WWW-Authenticate: Basic realm="CSP Viewer"');
header('HTTP/1.0 401 Unauthorized');
die('Unauthorized');
}
if (!file_exists($reportFile)) {
die('<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>No Reports</title></head><body><p>No reports found.</p></body></html>');
}
$lines = file($reportFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
$lines = array_reverse($lines);
$searchDocumentUri = isset($_GET['search_document_uri']) ? trim($_GET['search_document_uri']) : '';
$searchViolatedDirective = isset($_GET['search_violated_directive']) ? trim($_GET['search_violated_directive']) : '';
$perPage = 100;
$totalReports = count($lines);
$page = isset($_GET['page']) ? max(1, (int)$_GET['page']) : 1;
$offset = ($page - 1) * $perPage;
$lines = array_slice($lines, $offset, $perPage);
$currentType = 'Unknown';
if (!empty($lines)) {
$latest = json_decode($lines[0], true);
if ($latest && isset($latest['type'])) {
$currentType = $latest['type'];
}
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CSP Reports Viewer</title>
<style>
body {
font-family: sans-serif;
margin: 2rem;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 0.5rem;
border: 1px solid #ccc;
vertical-align: top;
}
td {
overflow-wrap: break-word;
word-break: break-all;
}
th {
background-color: #eee;
}
form input[type="text"] {
width: 300px;
}
</style>
</head>
<body>
<h1>CSP Reports</h1>
<p>Current report type: <strong style="color:blue;"><?= htmlspecialchars($currentType) ?></strong></p>
<!-- ダウンロードボタン:リンクをクリックすると、現在のページに ?download=csv が付与されてリクエスト -->
<p><a href="?download=csv" style="display:inline-block; padding:0.5em 1em; background:#0073aa; color:#fff; text-decoration:none; border-radius:4px;">Download CSV</a></p>
<form method="get" style="margin-bottom:1em; display:flex; gap:1em; flex-wrap:wrap;">
<label>Filter by Document URI: <input type="text" name="search_document_uri" value="<?= htmlspecialchars($searchDocumentUri) ?>"></label>
<label>Filter by Violated Directive: <input type="text" name="search_violated_directive" value="<?= htmlspecialchars($searchViolatedDirective) ?>"></label>
<div>
<button type="submit">Filter</button>
<a href="<?= htmlspecialchars($_SERVER['PHP_SELF']) ?>" style="margin-left:10px; border: 1px solid #999; padding: 3px 10px; text-decoration: none;">Clear</a>
</div>
</form>
<table>
<tr>
<th>Time</th>
<th>Document URI</th>
<th>Violated Directive</th>
<th>Blocked URI</th>
<th>Referrer</th>
<th>Status</th>
<th>Script Sample</th>
<th>Line Number</th>
</tr>
<?php
foreach ($lines as $line) {
$entry = json_decode($line, true);
if (!$entry || empty($entry['report'])) continue;
$dt = DateTime::createFromFormat('U.u', sprintf('%.6F', $entry['time']));
if ($dt) {
$dt->setTimezone(new DateTimeZone('Asia/Tokyo'));
$time = $dt->format('Y-m-d H:i:s.v');
} else {
$time = '-';
}
if ($entry['type'] === 'report-uri') {
outputRow($entry['report'], $time, 'report-uri', $searchDocumentUri, $searchViolatedDirective);
} elseif ($entry['type'] === 'report-to' && is_array($entry['report'])) {
foreach ($entry['report'] as $rep) {
if (!isset($rep['body'])) continue;
outputRow($rep['body'], $time, 'report-to', $searchDocumentUri, $searchViolatedDirective);
}
}
}
?>
</table>
<?php
$totalPages = max(1, ceil($totalReports / $perPage));
echo '<div style="margin-top:1em;">';
for ($i = 1; $i <= $totalPages; $i++) {
if ($i == $page) {
echo "<strong>$i</strong> ";
} else {
$url = htmlspecialchars($_SERVER['PHP_SELF']) . '?page=' . $i;
echo "<a href=\"$url\">$i</a> ";
}
}
echo '</div>';
?>
</body>
</html>
<?php
function outputRow($report, $time, $type, $searchDocumentUri, $searchViolatedDirective) {
$documentUri = $type === 'report-uri' ? $report['document-uri'] ?? '' : $report['documentURL'] ?? '';
$violatedDirective = $type === 'report-uri' ? $report['violated-directive'] ?? '' : $report['effectiveDirective'] ?? '';
$blockedUri = htmlspecialchars($type === 'report-uri' ? $report['blocked-uri'] ?? '-' : $report['blockedURL'] ?? '-');
$referrer = htmlspecialchars($report['referrer'] ?? '-');
$statusCode = htmlspecialchars($type === 'report-uri' ? $report['status-code'] ?? '-' : $report['statusCode'] ?? '-');
$scriptSample = htmlspecialchars($type === 'report-uri' ? $report['script-sample'] ?? '-' : $report['sample'] ?? '-');
$lineNumber = htmlspecialchars($type === 'report-uri' ? $report['line-number'] ?? '-' : $report['lineNumber'] ?? '-');
if ($searchDocumentUri !== '' && stripos($documentUri, $searchDocumentUri) === false) return;
if ($searchViolatedDirective !== '' && stripos($violatedDirective, $searchViolatedDirective) === false) return;
echo '<tr>';
echo "<td>{$time}</td>";
echo "<td>" . htmlspecialchars($documentUri) . "</td>";
echo "<td>" . htmlspecialchars($violatedDirective) . "</td>";
echo "<td>{$blockedUri}</td>";
echo "<td>{$referrer}</td>";
echo "<td>{$statusCode}</td>";
echo "<td style='max-width:300px; overflow-wrap:anywhere; font-family:monospace;'>{$scriptSample}</td>";
echo "<td>{$lineNumber}</td>";
echo '</tr>';
}
// 保存されている CSP レポートを CSV 形式に変換し、即座にダウンロードを開始する関数
function generate_csv() {
// タイムゾーンを日本時間に設定
date_default_timezone_set('Asia/Tokyo');
// 保存されているCSPレポートファイル
$reportFile = __DIR__ . '/csp-reports/reports.json';
// レポートファイルがなければ終了
if (!file_exists($reportFile)) {
die('No reports found.');
}
// ブラウザにCSVとしてダウンロードさせるためのヘッダーを送信
header('Content-Type: text/csv; charset=UTF-8');
header('Content-Disposition: attachment; filename="csp-reports.csv"');
// 出力先を php://output にして直接ブラウザに書き込む
$output = fopen('php://output', 'w');
// CSVのヘッダー行を書き込む
fputcsv($output, [
'Time',
'Document URI',
'Violated Directive',
'Blocked URI',
'Referrer',
'Status',
'Script Sample',
'Line Number'
]);
// レポートを行単位で読み込み
$lines = file($reportFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
// 新しいレポートが先になるように逆順に並び替える
$lines = array_reverse($lines);
foreach ($lines as $line) {
// 1行をJSONデコードしてレポートデータに変換
$entry = json_decode($line, true);
if (!$entry || empty($entry['report'])) continue; // 無効なレポートはスキップ
// タイムスタンプを日本時間の日付に変換
$time = date('Y-m-d H:i:s', (int)$entry['time']);
// report-uri形式のレポートの場合
if ($entry['type'] === 'report-uri') {
$report = $entry['report'];
$documentUri = $report['document-uri'] ?? '';
$violatedDirective = $report['violated-directive'] ?? '';
$blockedUri = $report['blocked-uri'] ?? '-';
$referrer = $report['referrer'] ?? '-';
$statusCode = $report['status-code'] ?? '-';
$scriptSample = $report['script-sample'] ?? '-';
$lineNumber = $report['line-number'] ?? '-';
// report-to形式のレポートの場合
} elseif ($entry['type'] === 'report-to' && isset($entry['report'][0]['body'])) {
// report-toは配列なので最初の要素のbodyを取得
$report = $entry['report'][0]['body'];
$documentUri = $report['documentURL'] ?? '';
$violatedDirective = $report['effectiveDirective'] ?? '';
$blockedUri = $report['blockedURL'] ?? '-';
$referrer = $report['referrer'] ?? '-';
$statusCode = $report['statusCode'] ?? '-';
$scriptSample = $report['sample'] ?? '-';
$lineNumber = $report['lineNumber'] ?? '-';
} else {
continue; // フォーマットが不明なレポートはスキップ
}
// レポート内容をCSV1行として書き込む
fputcsv($output, [
$time,
$documentUri,
$violatedDirective,
$blockedUri,
$referrer,
$statusCode,
$scriptSample,
$lineNumber
]);
}
// 出力を閉じて終了
fclose($output);
}
generate_csv()
関数 generate_csv() は、保存されている CSP レポートを CSV 形式に変換し、即座にダウンロードを開始するためのものです。
- header() で CSV のダウンロード開始を指示(Content-Type と Content-Disposition でブラウザに「これは CSV ファイルである」と伝え、保存ダイアログを出す)
- php://output: 出力をファイルに保存するのではなく、直接 HTTP レスポンスとしてブラウザに送信。
- レポートは JSON Lines 形式のテキストファイルに1レポート1行で保存されています。
- レポートには「report-uri」と「report-to」の2種類の形式があり、それぞれデータ構造が異なるため、条件分岐して CSV に整形しています。
- CSVとして出力するために、PHP の fputcsv() 関数を使って、1行ずつブラウザに出力しています。
// fopen() で仮想ファイル「php://output」を開く
$output = fopen('php://output', 'w');
- fopen() で仮想ファイル「php://output」を開いています。
- 「php://output」は、サーバーがクライアント(ブラウザ)に返す HTTP レスポンスに直接書き込むための特別な出力ストリームです。
- fwrite() や fputcsv() などでこの $output に書き込むと、ファイルに保存されるのではなく、即座に HTTP レスポンスとして送信されます。
CSP ポリシー補助ツール
CSP ポリシーの作成やテストを補助する以下のようなツールがあります。
CSP Evaluator (Google)
ポリシーの安全性評価
- Google製のツールで、入力したCSPがどれくらい安全か、どんな弱点があるかを分析してくれます。
- 例えば、'unsafe-inline' の危険性や、許可ドメインの範囲が広すぎる点などを指摘。
- https://csp-evaluator.withgoogle.com/
CSP Generator (Report URI)
ポリシー生成・カスタマイズ
- 左側のディレクティブを選んでクリックしていくだけで、CSPポリシーの雛形を作成できます。
- Gravatar、Google Fonts などの主要ドメインもあらかじめ選択可能。
- https://report-uri.com/home/generate
Mozilla HTTP Observatory
セキュリティのベストプラクティスへの準拠状況を分析
- Mozilla のセキュリティ基準に基づいた安全性を確認できます。
- https://observatory.mozilla.org/
参考サイト
Content Security Policy の詳細や設定方法などについては以下のサイトで確認できます。
- MDN:
- W3C Working Draft:
- web.dev:
- AdSense:
- Google Developer