CSPのリストアップを眺めてて、「へ~大変だな~、そういえばうちのサイトってどうなってんだっけ」と思ったら、有効ではあったけど言うだけいったろくらいの設定だったので、しっかり直しました。
なんでセキュリティヘッダーなんてちゃんと入れようとしてたんだっけ? あれか? Claudeに「良いサイトにして♡」とだけ丸投げしていろいろやらせていた時の名残か?
CSPとは
CSP(Content Security Policy)とは、Webページが「どこのサーバーからリソースを読み込んでよいか」をサーバー側が宣言するHTTPヘッダーです。
Content-Security-Policy: script-src 'self' https://cdn.example.com;
ホワイトリストに載っていないドメインからのJS・CSSなどの読み込みを、ブラウザがブロックします(どのリソース種別をどこまで縛るかはディレクティブごとに設定でき、たとえば画像は緩めにする、といった運用も可能です)。XSS(クロスサイトスクリプティング)への防御として有効で、攻撃者が悪意あるスクリプトを注入しても、許可されていないドメインからの読み込みは実行されません。
なお、CSPには試験用の Content-Security-Policy-Report-Only というヘッダーがあります。これはブロックせず、違反をログに記録するだけのモードで、本番適用前の動作確認に使います。
まずレスポンスヘッダーを確認
curl -sI でヘッダーだけ取ってみます。
curl -sI https://subara3.com/

……あれ、CSP設定されてる。しかも結構ちゃんとした内容で。AdSense(googlesyndication.com 等)やCDN(cdn.jsdelivr.net、cdnjs.cloudflare.com)まで一通りホワイトリストに入っていました。他のセキュリティヘッダーも設定されてますね。X-Frame-Optionsとか。そーなんだ。
| ヘッダー | 設定値 |
|---|---|
Strict-Transport-Security |
max-age=31536000; includeSubDomains; preload |
X-Frame-Options |
SAMEORIGIN |
X-Content-Type-Options |
nosniff |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
geolocation=(), camera=(), microphone=() |
うちはロリポップ(共有レンタルサーバー)なので、CSPはCDNやWAFではなくPHP側の header() で出していました(includes/security-headers.php を各エントリーポイントの先頭で require する形)。なので後で出てくる修正も、Cloudflareの管理画面ではなくPHPファイルの編集になります。
問題点①:Report-Onlyのまま
ヘッダー名が content-security-policy-report-only になっています。つまりブロックは一切していない状態です。
| ヘッダー | 挙動 |
|---|---|
Content-Security-Policy |
違反をブロックする(本番) |
Content-Security-Policy-Report-Only |
ブロックせず違反を記録するだけ |
Report-Onlyは「本番適用前に何が引っかかるか確認するためのモード」なので、いつまでも置いておくものではありません。観測 → 修正 → 本番、と進めて初めて意味があります。
問題点②:report-uriが無い
CSP違反をサーバー側で集めるには report-uri(または report-to)でエンドポイントを指定する必要があります。これが無いと、違反はユーザーのブラウザのコンソールにしか出ない=こちらからは永遠に見えません。
違反を実際に洗い出す
F12のコンソールを目視してもいいのですが、今回はヘッドレスブラウザ(Playwright)で実際にページを読み込み、securitypolicyviolation イベントとコンソール出力を機械的に集めました。出ていた違反がこちらです。

不足していたのは次の2系統のドメインでした。
fundingchoicesmessages.google.com… AdSenseの同意管理(「広告を表示してよいですか?」のGDPR等対応UI)が使うep1/ep2.adtrafficquality.google… Google広告の不正クリック検出(sodar)が使う
ハマりどころ:1つのドメインが複数ディレクティブで弾かれる
「同意管理はスクリプトだから script-src に足せばいい」「不正クリック検出はiframeだから frame-src だけ」みたいなことはないようです。
| ドメイン | 実際に違反したディレクティブ |
|---|---|
fundingchoicesmessages.google.com |
script-src と connect-src |
*.adtrafficquality.google |
script-src と connect-src と frame-src |
同じドメインがスクリプトの読み込み・fetch/beacon 通信・iframe表示と、複数の用途で使われるため、複数のディレクティブに同時に引っかかります。「どのディレクティブに足すか」は推測ではなく実際の違反ログで確認するのが鉄則だと痛感しました。
ポイント:広告系ドメインは多段ロードで増える
AdSenseを置くと、HTMLに書いた pagead2.googlesyndication.com だけでなく、そのJSがさらに別ドメインを動的に読み込みます。fundingchoicesmessages も adtrafficquality も、HTMLソースには一切書いていません。HTMLを目視するだけでは絶対に見つかりません。 F12のNetworkタブの Initiator列(このリクエストはどのJSが起点か)や、今回のように違反イベントを集める方法が要ります。
対応①:ホワイトリストを直す
違反ログに基づいて、PHPのCSP文字列に不足ドメインを追加しました。
// script-src と connect-src の両方に追加
... https://fundingchoicesmessages.google.com https://*.adtrafficquality.google
// frame-src にも追加
frame-src ... https://*.adtrafficquality.google;
対応②:report-uriエンドポイントを用意する
外部サービス(Report URI など)を使う手もありますが、今回は違反を自前のログに落とすだけの軽いPHPを置きました。公開エンドポイントなので、本文サイズとログファイルサイズに上限を設けてフラッディング対策をしています(ログ置き場は .htaccess で直接アクセス禁止済み)。
// /csp-report.php(要点)
$raw = file_get_contents('php://input', false, null, 0, 16384); // 16KB上限
if (is_file($logFile) && filesize($logFile) > 5 * 1024 * 1024) { exit; } // 5MB上限
// report-uri形式 {"csp-report":{...}} と report-to形式の両方をパースして1行に整形
そしてCSPに report-uri /csp-report.php; を追加します。
対応③:本番を触らずに「切り替えても大丈夫か」を先に確認
Content-Security-Policy-Report-Only を Content-Security-Policy に切り替える=ブロックが始まるので、ホワイトリストに抜けがあると正規のリソースまで止まります。広告が消えたら本末転倒です。
そこで、本番のヘッダーを書き換える前に、Playwrightでレスポンスヘッダーだけを新ポリシー(enforcing)に差し替えてライブのページを読み込み、何かブロックされないかを観測しました。

トップページと記事ページで違反0件。これで「切り替えても何も壊れない」と確認できてから、ようやく本番のPHPを書き換えました。
切り替え完了
Content-Security-Policy-Report-Only を Content-Security-Policy に変更してデプロイ。切り替え後のヘッダーがこちらです。

切り替え後、改めてライブを読み込んで違反0件・広告も同意UIも正常表示、report-uri のエンドポイントもテスト送信で正しくログに記録されることを確認しました。
なお余談ですが、今回いじったCSPはPHPで描画しているページ(トップ・記事など)にしか乗っていません。/tool/ 以下の静的HTMLツール群にはそもそもCSPヘッダーが付いていない、という現状も確認できました。ここは今後の課題です。
注意:unsafe-inline について
今回の設定には 'unsafe-inline' が含まれています。
script-src 'self' 'unsafe-inline' ...
unsafe-inline はHTMLに直接書いた <script> の中身を全部許可するので、これがあるとXSS対策としてのCSPの効果はかなり薄れます。本来は nonce 方式(リクエストごとにランダムなトークンを発行してscriptタグに付ける)に移行するのがベストですが、既存テンプレート全体の改修が必要なのでコスト相談……ということで、今回はここまで。
まとめ
- CSPは設定して終わりではなく、Report-Onlyで観測 → ホワイトリスト修正 → enforcing切替まで進めて初めて機能する
- 広告系スクリプトは多段ロードでドメインが増える。HTMLソースだけでは全量が把握できない
- 1つのドメインが
script-src・connect-src・frame-srcと複数ディレクティブで弾かれることがある。足すディレクティブは推測せず違反ログで確認 - enforcingへの切替は、本番を触る前にヘッダー差し替えでシミュレーションしておくと安全
report-uriが無いと違反はユーザーのコンソールにしか出ない。本番運用では用意しておきたいunsafe-inlineは応急処置。本格対応はnonce方式への移行が推奨
個人サイトだから……と後回しにしがちですが、広告を貼っている時点でマルバタイジング(広告経由のマルウェア配信)のリスクはゼロではありません。CSPは静的寄りのサイトでも有効な防御の一枚です。