CSP(Content-Security-Policy)の導入手順
CSP は「XSS の穴が残っていても、攻撃コードを実行させない」ための最後の砦です。ただし、いきなり本適用すると広告タグも解析タグも黙って止まります。壊さずに入れる順番を、実際のヘッダ文字列と設定例で説明します。
CSP とは何ですか?
CSP(Content-Security-Policy)は「このページはどこから何を読み込んでよいか」をブラウザに宣言する HTTP レスポンスヘッダです。宣言に反する読み込みや実行はブラウザ側で遮断されるため、万一 XSS の穴が残っていても、攻撃コードが実行される最後の一歩を止められます。
XSS 対策の基本は、あくまで出力時のエスケープです。CSP はそれを置き換えるものではなく、「エスケープが 1 箇所漏れていた」ときに被害を止める 2 枚目の防御です。実際のインシデントの多くは、自社コードではなく、後から足された外部タグや、ライブラリの依存の奥で起きます。 すべてのコードを完璧に監査し続けるより、ブラウザ側に「読み込んでよい場所」を宣言させるほうが現実的です。
CSP が防ぐのは、たとえば次のようなものです。
- 投稿欄に仕込まれたスクリプトが、閲覧者の Cookie を外部へ送信する
- 改ざんされた CDN 上のライブラリが、入力されたカード番号を攻撃者のサーバーへ POST する
<base>タグを注入して、ページ内の相対リンクの行き先を丸ごと差し替える
なぜ 'unsafe-inline' を書くと CSP はほぼ無意味になるのですか?
XSS で注入されるコードの大半は、HTML に直接埋め込まれるインラインスクリプトです。'unsafe-inline' はそのインラインスクリプトの実行を全面的に許可する指定なので、これを書いた時点で CSP が防ぐはずだった攻撃の大半が素通りします。許可リストに何を並べても、この 1 語で台無しになります。
本ツールの診断でも、script-src に 'unsafe-inline' があり、かつ nonce も hash も無い場合は 「設定あり」ではなく警告として扱います。ヘッダが存在することと、防御が効いていることは別だからです。
では、なぜ多くのサイトが 'unsafe-inline' を外せないのか。理由ははっきりしていて、広告・解析タグがインラインスクリプトを前提に作られているからです。GTM のスニペットも、AdSense の初期化も、HTML に直書きされます。 これを外すと計測も収益も止まる —— だから付けたまま放置される、という構図です。
この行き詰まりを解くのが、次に説明する nonce と 'strict-dynamic' です。タグを残したまま 'unsafe-inline' を無効化できます。
広告・解析タグを壊さずに導入するには、どう進めればよいですか?
いきなり Content-Security-Policy を付けず、まず Content-Security-Policy-Report-Only で 1〜2 週間運用します。Report-Only は違反を検知して報告するだけで、実際には何もブロックしません。本番の実トラフィックで漏れを洗い出し、潰し切ってから本適用に切り替えます。
実際の移行は、次の 5 段階で進めます。焦って 1 と 4 を飛ばすと、たいてい事故ります。
- Report-Only で緩めに配信 — 本適用したいポリシーを Content-Security-Policy-Report-Only で流す。この時点では何もブロックされないので、いつ入れても安全です。
- 実トラフィックで違反を集める — 1〜2 週間。開発環境では絶対に出ない違反が出ます。広告の配信先、A/B テストツール、チャットウィジェット、ブラウザ拡張など。
- 許可漏れを潰す — 自分たちが意図して入れているものだけを許可します。「違反が出たから全部足す」をやると許可リストが無意味になります。
- 本適用に切り替える — ヘッダ名から -Report-Only を外すだけ。切り替え後も数日はエラーレポートと広告収益を監視します。
- nonce + strict-dynamic へ移行 — ここで初めて 'unsafe-inline' が外れ、CSP が本来の防御力を持ちます。ここまでやらないと、実は何も守れていません。
Report-Only のヘッダは具体的にどう書きますか?
ヘッダ名を Content-Security-Policy-Report-Only にして、本適用したいポリシーをそのまま書きます。違反は DevTools の Console に出るほか、report-uri で指定した URL へ JSON で POST されます。ヘッダ名以外は本適用と同じなので、そのまま名前を変えるだけで移行できます。
nginx の場合
# まずは Report-Only。ブロックせず違反だけ報告させる
# 実際のヘッダ値は改行できない 1 行。ここでは読みやすさのため折り返している
add_header Content-Security-Policy-Report-Only "
default-src 'self';
script-src 'self' 'unsafe-inline'
https://www.googletagmanager.com
https://pagead2.googlesyndication.com
https://*.googlesyndication.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://www.google-analytics.com;
frame-src https://googleads.g.doubleclick.net;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'self';
report-uri /api/csp-report;
" always;Caddy の場合
example.com {
header {
# 本適用(Report-Only で漏れを潰し切ってから)
Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com; object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
# ついでに: サーバーのバージョン露出を消す
-Server
}
reverse_proxy localhost:3000
}違反レポートの送り先は、歴史的には report-uri、現在の仕様では Reporting-Endpoints ヘッダ + report-to ディレクティブです。ただしブラウザの対応状況が揃っていないため、当面は両方を書いておくのが現実的です。 レポートを受ける口を用意していない場合でも、DevTools の Console には違反が出るので、Report-Only を試す価値は十分あります。
nonce はどう実装しますか?
nonce は、リクエストごとに生成したランダム値を CSP ヘッダと script タグの両方に書く方式です。攻撃者は HTML を注入する時点で次の nonce を知り得ないため、'unsafe-inline' を外したままインラインスクリプトを 1 つずつ許可できます。値はリクエストごとに必ず変えてください。
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
// ★ リクエストごとに新しい値を生成する。
// 使い回した瞬間に nonce は攻撃者にとって既知の値になり、意味を失う
const nonce = crypto.randomUUID().replace(/-/g, "");
const csp = [
"default-src 'self'",
// 'strict-dynamic' … nonce を付けたスクリプトが読み込む子スクリプトを自動的に信頼する。
// → GTM / AdSense の配信ドメインを列挙しなくても動く
// https: … strict-dynamic 非対応ブラウザ向けのフォールバック
// 'unsafe-inline' … 同上。nonce に対応したブラウザは **この指定を無視する** ので穴にならない
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'self'",
"upgrade-insecure-requests",
].join("; ");
// 生成した nonce を、描画側から読めるようリクエストヘッダに載せて引き渡す
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-nonce", nonce);
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set("Content-Security-Policy", csp);
return res;
}
export const config = {
// 静的アセットには不要。無駄にヘッダを付けない
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};描画側では、middleware が載せた nonce を読んで script タグに渡します。
import { headers } from "next/headers";
import Script from "next/script";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const nonce = (await headers()).get("x-nonce") ?? undefined;
return (
<html lang="ja">
<body>
{children}
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"
nonce={nonce}
strategy="afterInteractive"
/>
</body>
</html>
);
}許可リストに広告ドメインを並べ続けないといけないのですか?
'strict-dynamic' を使えば不要になります。これは「nonce を付けて信頼したスクリプトが動的に読み込む子スクリプトも、同じく信頼する」という指定です。GTM や AdSense のようにタグがタグを呼ぶ構造でも、配信ドメインを列挙せずに動きます。維持できない許可リストを抱えずに済みます。
上の middleware の例で、'strict-dynamic' と 'unsafe-inline' が同居しているのを不審に思ったかもしれません。 これは意図的な書き方で、ブラウザごとに解釈が上書きされることを利用しています。
| ブラウザの対応 | 実際に効く指定 | 結果 |
|---|---|---|
| nonce + strict-dynamic 対応(現行の主要ブラウザ) | 'nonce-…' + 'strict-dynamic' | 最も強い防御。 nonce 付きスクリプトとその子だけが動く。'unsafe-inline' と https: は無視される |
| nonce 対応・strict-dynamic 非対応 | 'nonce-…' + https: | HTTPS 上のスクリプトは許可。防御は弱まるが動作は壊れない |
| nonce 非対応(かなり古い環境) | 'unsafe-inline' | 従来どおり動く。防御は無いが、サイトが真っ白になるよりましという判断 |
重要なのは 1 行目です。nonce を解釈できるブラウザは 'unsafe-inline' を意図的に無視します(仕様でそう決まっています)。 つまりこの 'unsafe-inline' は穴ではなく、古い環境向けのフォールバックとして安全に置けます。 「'unsafe-inline' は絶対悪」ではなく、nonce と併記されているかどうかが分かれ目です。
どのディレクティブから設定すればよいですか?
まず script-src・object-src・base-uri の 3 つです。XSS の実行経路はほぼここに集約されるためで、逆に img-src や font-src を厳しくしても防げる攻撃はほとんど増えません。最小構成なら default-src 'self' に、この 3 つを足した形から始めれば十分に実用的です。
| ディレクティブ | 何を止めるか | 推奨値 |
|---|---|---|
script-src | JS の読み込みと実行。XSS 防御の本体 | 'nonce-…' 'strict-dynamic' |
object-src | Flash / embed 経由のスクリプト実行 | 'none' |
base-uri | base タグ注入による相対リンクの乗っ取り | 'self' |
form-action | フォーム送信先の差し替え(情報の持ち出し) | 'self' |
frame-ancestors | 他サイトへの埋め込み=クリックジャッキング | 'self' |
default-src | 個別指定が無いときの既定値 | 'self' |
img-src | 画像の読み込み元 | 'self' data: https: |
style-src | CSS の読み込み元 | 'self' 'unsafe-inline' |
connect-src | fetch / XHR / WebSocket の接続先 | 'self' + 解析ドメイン |
style-src の 'unsafe-inline' は、正直なところ多くのサイトで外せません(CSS-in-JS も、要素の style 属性も動かなくなるため)。 ただしCSS のインライン許可は script ほど危険ではありません。情報を抜き取る手口が理論上は存在するものの、任意コード実行には至らないためです。script-src だけは絶対に妥協しない、という優先順位で構いません。
CSP は SEO や広告収益に悪影響がありますか?
ヘッダ自体は検索順位にもページ速度にも影響しません(数百バイト増えるだけです)。悪影響が出るのは設定を誤ったときだけで、広告タグがブロックされれば収益が、解析タグが止まればデータが失われます。だからこそ Report-Only での検証期間を省かないでください。
あわせて frame-ancestors にも注意してください。'none' にすると、自社サイトを iframe で埋め込んでいる提携先やプレビュー機能が壊れます。 クリックジャッキング対策としては 'self' で十分なことがほとんどです。詳しくは セキュリティヘッダ 7 種の意味と設定 で、X-Frame-Options との使い分けを含めて解説しています。
なお、CSP は構造化データにも影響しません。<script type="application/ld+json"> は実行されるスクリプトではなくデータなので、 CSP でブロックされることはありません。JSON-LD の書き方は 構造化データ(JSON-LD)の書き方 を参照してください。
一次情報は MDN の CSP 解説 と W3C の CSP Level 3 仕様 が正確です。実際に配信されているポリシーの強度は CSP Evaluator で評価できます。