Content Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策
CSP はブラウザに「どのオリジンから何を読み込んでよいか」を伝える許可リスト方式の防御層だ。XSS 対策の本命は script-src に nonce + strict-dynamic を入れ 'unsafe-inline' を外すこと。クリックジャッキングは frame-ancestors で遮断する。いきなり強制モードにせず、Report-Only で段階導入してから本番ヘッダーへ移行するのが実務の定石。
CSP(Content Security Policy)とは
Content Security Policy(CSP)は、HTTP レスポンスヘッダー Content-Security-Policy でブラウザにセキュリティルールを渡す仕組みだ。入力のサニタイズや出力エスケープが第一防衛線なら、CSP は最後の防波堤として、たとえ悪意ある HTML が混入してもスクリプト実行や外部リソース読み込みを抑える。
CSP Level 3 では script-src-elem / script-src-attr のように要素単位の制御も可能だが、実務ではまず default-src と script-src を固め、必要に応じて style-src・connect-src・img-src を追加する形が多い。
ポリシー違反が起きると、ブラウザは該当リソースの読み込みや実行をブロックし、Console にエラーを出す。Report-Only モード(Content-Security-Policy-Report-Only)ならブロックせずレポートだけ送る——本番を壊さない移行に必須の手段だ。
ディレクティブリファレンス
CSP はセミコロン区切りのディレクティブで構成される。よく使うものを整理する。
| ディレクティブ | 役割と設定のヒント |
|---|---|
| default-src | 他ディレクティブで未指定のリソース種別のデフォルト。default-src 'self' をベースに個別上書きする。 |
| script-src | JavaScript の実行元。nonce・hash・strict-dynamic の主戦場。unsafe-eval は可能な限り避ける。 |
| style-src | CSS の読み込み元。フレームワークのインライン style が多いと 'unsafe-inline' が必要になることがある。 |
| img-src | 画像。data: や CDN ドメインを明示しないとアイコンや OGP 画像がブロックされる。 |
| connect-src | fetch・XHR・WebSocket・EventSource の接続先。SPA の API 呼び出し漏れはここで検出される。 |
| font-src | Web フォント。Google Fonts 利用時は fonts.gstatic.com を許可する。 |
| object-src | Flash 等のプラグイン。'none' 固定が推奨。 |
| base-uri | <base> タグの href。'self' または 'none' でベース URL 改ざんを防ぐ。 |
| frame-ancestors | このページを iframe に埋め込める親。クリックジャッキング対策の本命(後述)。 |
| upgrade-insecure-requests | HTTP リソースを HTTPS に自動アップグレード。混在コンテンツ対策。 |
| report-uri / report-to | 違反レポートの送信先。report-to は Reporting API と組み合わせる(後述)。 |
ソース式の読み方
| ソース式 | 意味 |
|---|---|
'self' | 同一オリジン(スキーム・ホスト・ポート一致) |
'none' | 完全拒否 |
'unsafe-inline' | インライン script / style を許可(XSS 時に無防備になるため本番では避ける) |
'unsafe-eval' | eval()・new Function() を許可(サードパーティ SDK が要求することがある) |
'nonce-...' | 一致する nonce 属性を持つ要素だけ許可 |
'sha256-...' | 内容のハッシュが一致するインライン script だけ許可 |
'strict-dynamic' | nonce/hash で信頼された script が追加した子 script を連鎖許可 |
https://cdn.example.com | 特定オリジンを許可 |
default-src だけ書いて script-src を省略すると、script も default に従う。意図せず緩く/厳しくならないよう、script-src は明示的に書くのが安全だ。
インラインスクリプト問題
CSP 導入で最初にぶつかるのがインラインスクリプトだ。次のようなコードはすべて script-src の対象になる。
<!-- インライン script タグ -->
<script>window.__CONFIG__ = { api: '/v1' };</script>
<!-- イベントハンドラ属性 -->
<button onclick="submit()">送信</button>
<!-- javascript: URL -->
<a href="javascript:alert(1)">クリック</a>
従来は script-src 'self' 'unsafe-inline' と書けば動いた。しかし XSS で <script>窃取コード</script> が注入されると、ブラウザは「インラインだが unsafe-inline で許可されている」と判断しそのまま実行してしまう。つまり CSP が XSS 防御として機能しない。
解決策1: nonce(Number used ONCE)
サーバーがリクエストごとにランダムな nonce を生成し、CSP ヘッダーと HTML の <script nonce="..."> に同じ値を付ける。攻撃者は nonce を知らないため、注入されたインライン script は実行されない。
<script nonce="k7fG9p2mQx1nR4vL8wY3zA==">
window.__CONFIG__ = { api: '/v1' };
</script>
nonce はレスポンスごとに一意である必要がある。静的 HTML を CDN で全ユーザー共通配信していると nonce が固定され、意味がなくなる。SSR・エッジで動的生成するか、hash 方式に切り替える。
解決策2: hash(sha256 等)
内容が変わらない小さなインライン script なら、sha256 ハッシュで許可できる。デプロイのたびに内容が変わるなら hash の再計算が必要だ。
イベントハンドラ属性は別問題
onclick などの HTML 属性内スクリプトは script-src-attr(または script-src)で制御される。nonce では許可できない。React / Vue などフレームワークのイベントバインディングに寄せ、属性内 JavaScript を廃止するのが CSP 3 時代の定石だ。
移行期にだけ 'unsafe-inline' を残すチームは多いが、XSS が入った瞬間に防御が無効化される。Report-Only で影響範囲を把握し、nonce / hash / 外部ファイル化で段階的に外す。
strict-dynamic によるモダンポリシー設計
バンドラ(Webpack・Vite・esbuild)やフレームワークは、nonce 付きのエントリ script から動的に <script> を追加することがある。'self' だけでは「後から追加された同一オリジンの script」がブロックされる場合がある。
strict-dynamic は、nonce(または hash)で信頼された script が document.createElement('script') 等で追加した子 script を連鎖的に許可するディレクティブだ。
推奨ポリシー文字列の例
Content-Security-Policy: default-src 'self'; script-src 'nonce-k7fG9p2mQx1nR4vL8wY3zA==' 'strict-dynamic' 'self' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; upgrade-insecure-requests
ポイントは次のとおり。
- nonce — そのリクエストで信頼するインライン/エントリ script に付与
- strict-dynamic — 信頼 script から派生する動的 script を許可
- ‘self’ https: — strict-dynamic 非対応ブラウザ向けフォールバック(
'unsafe-inline'は含めない) - object-src ‘none’ — プラグイン経路を閉じる
- frame-ancestors ‘none’ — クリックジャッキング対策(後述)
CSP Level 3 では strict-dynamic が指定されていると、同じ script-src 内のホストソースや 'unsafe-inline' は無視される(nonce/hash 付き script 以外はブロック)。モダンブラウザ向けに意図した動きだ。
frame-ancestors でクリックジャッキング対策
クリックジャッキングは、攻撃者サイトが透明な <iframe> で正規サイトを重ね、ユーザーに「ログインボタンを押したつもり」で別の操作をさせる攻撃だ。JavaScript が不要なため、script-src だけでは防げない。
frame-ancestors は「このページを誰が iframe に埋め込めるか」を制御する。X-Frame-Options の後継で、CSP ではこちらが推奨される。
Content-Security-Policy: frame-ancestors 'none'
| 設定値 | 効果 |
|---|---|
'none' | どのサイトからも iframe 埋め込み不可(管理画面・決済画面向け) |
'self' | 同一オリジンのみ許可 |
https://partner.example.com | 指定オリジンのみ許可(ウィジェット配信など) |
自社の別ドメインだけ許可する例:
frame-ancestors 'self' https://dashboard.example.com https://embed.example.com
<meta http-equiv="Content-Security-Policy"> に frame-ancestors を書いてもブラウザは無視する。クリックジャッキング対策は必ず HTTP ヘッダーで返す。
Nginx で CSP ヘッダーを設定する
静的サイトやリバースプロキシの手前で CSP を付与する場合、nginx の add_header を使う。以下は本番でよく使う構成の一例だ。
server {
listen 443 ssl http2;
server_name www.example.com;
ssl_certificate /etc/nginx/ssl/example.com.crt;
ssl_certificate_key /etc/nginx/ssl/example.com.key;
root /var/www/example/dist;
index index.html;
# 静的サイト向け CSP(外部 script は同一オリジンのみ)
add_header Content-Security-Policy "
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
upgrade-insecure-requests
" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
try_files $uri $uri/ /index.html;
}
# アプリサーバーへプロキシする場合はオリジンが CSP を付与
location /app/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# アプリの Content-Security-Policy をそのまま返す
proxy_pass_header Content-Security-Policy;
}
}
nonce を使う SSR の場合、nginx 単体ではリクエストごとの乱数生成が難しい。Node / Express などアプリサーバーでヘッダーを付与し、nginx は proxy_pass_header で透過させるのが一般的だ。nginx njs モジュールで nonce を生成する方法もあるが、運用コストはアプリ側生成の方が低いことが多い。
always キーワードを付けないと、エラーレスポンス(404 / 502)で CSP ヘッダーが落ちる。セキュリティヘッダーは常に付与する。
Node.js / Express で nonce を生成するミドルウェア
SSR や API 付きフロントでは、リクエストごとに nonce を生成し、CSP ヘッダーと HTML テンプレートへ渡す。
import crypto from 'node:crypto';
import express from 'express';
const app = express();
/** リクエストごとに CSP nonce を生成 */
function cspNonceMiddleware(req, res, next) {
res.locals.cspNonce = crypto.randomBytes(16).toString('base64');
next();
}
/** CSP ヘッダーを組み立てて付与 */
function cspHeaderMiddleware(req, res, next) {
const nonce = res.locals.cspNonce;
const directives = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https:`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https:",
"font-src 'self' https://fonts.gstatic.com",
"connect-src 'self' https://api.example.com",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
"upgrade-insecure-requests",
"report-uri https://reports.example.com/csp-violations",
];
res.setHeader('Content-Security-Policy', directives.join('; '));
next();
}
app.use(cspNonceMiddleware);
app.use(cspHeaderMiddleware);
// EJS / Pug / React SSR などに nonce を渡す
app.get('/', (req, res) => {
const nonce = res.locals.cspNonce;
res.send(`<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>Example</title>
</head>
<body>
<script nonce="${nonce}">
window.__BOOTSTRAP__ = { apiBase: '/api' };
</script>
<script nonce="${nonce}" src="/assets/main.js"></script>
</body>
</html>`);
});
app.listen(3000);
Express では res.locals に nonce を載せ、テンプレートエンジンや React の renderToString に渡す。Next.js の場合は middleware.ts で crypto.randomUUID() を使い、レスポンスヘッダーと _document / layout の script タグへ同じ値を埋め込む(後述)。
移行期は同じ nonce で Report-Only ヘッダーを併記できる。
res.setHeader(
'Content-Security-Policy-Report-Only',
directives.join('; ').replace(
"report-uri https://reports.example.com/csp-violations",
"report-uri https://reports.example.com/csp-violations-dry-run"
)
);
sha256 hash の計算例
変更頻度の低いインライン script(Google Analytics のスニペット断片、初期設定の数行など)は hash で許可できる。アルゴリズムは sha256 が一般的だ。
コマンドライン(OpenSSL)
script の内容は <script> タグを含めず、改行・スペースもそのままハッシュ対象にする。
# ハッシュ対象の文字列(末尾改行なし)
echo -n "console.log('analytics boot');" | openssl dgst -sha256 -binary | openssl base64
# 出力例: abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH==
CSP ヘッダーでは sha256- プレフィックスを付ける。
script-src 'sha256-abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH=='
Node.js(crypto モジュール)
import crypto from 'node:crypto';
const inlineScript = "console.log('analytics boot');";
const digest = crypto
.createHash('sha256')
.update(inlineScript, 'utf8')
.digest('base64');
const cspSource = `'sha256-${digest}'`;
console.log(cspSource);
// 'sha256-abcdefghijklmnopqrstuvwxyz1234567890ABCDEFGH=='
複数のインライン script があるなら、hash をスペース区切りで並べる。
script-src 'sha256-AAA...' 'sha256-BBB...' 'strict-dynamic'
1 文字でも内容が変わると hash は無効になる。ビルドパイプラインで自動生成するか、外部 .js ファイルへ移す方が運用は楽だ。
段階的な移行パス
CSP をいきなり強制モードで入れると、広告タグ・A/B テスト・古い CDN ドメインなど想定外の読み込み元で本番が壊れる。安全な移行は次の流れだ。
DevTools Network で script / style / img / font / connect の読み込み元を棚卸しする
Content-Security-Policy-Report-Only を全トラフィックに配信し、違反をブロックせず記録する
report-uri または report-to でレポートを集約し、許可漏れドメインをリスト化する
script-src に nonce + strict-dynamic を導入し、unsafe-inline を段階的に外す
frame-ancestors・object-src 'none'・base-uri を追加して攻撃面を狭める
問題がなくなったら Content-Security-Policy 強制ヘッダーに切り替える
Report-Only の詳細な運用フロー・report-to と Reporting API の設定・カナリア移行は、別記事 CSP Report-Only モードの実装ガイド で解説している。本記事でポリシー設計を固めたら、そちらの手順で本番投入するのがおすすめだ。
強制ヘッダーと Report-Only を同時に併記し、「本番は緩いポリシー」「Report-Only は将来の厳格版」という二段構えで運用するチームも多い。
Astro / Next.js での CSP 実装メモ
フレームワークごとに nonce の渡し方が異なる。押さえるべき点だけ整理する。
Astro
Astro はデフォルトでスクリプトをビルド時にバンドルする。@astrojs/node など SSR アダプタを使う場合、ミドルウェアで nonce を生成し Astro.locals に載せる。
// src/middleware.ts(Astro 5+)
import { defineMiddleware } from 'astro:middleware';
import crypto from 'node:crypto';
export const onRequest = defineMiddleware(async (context, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
context.locals.cspNonce = nonce;
const response = await next();
response.headers.set(
'Content-Security-Policy',
`default-src 'self'; script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'`
);
return response;
});
コンポーネント内の <script> に nonce を付けるには、is:inline ディレクティブと組み合わせてテンプレートへ埋め込む。静的エクスポート(output: 'static')のみのサイトはリクエスト単位の nonce が使えないため、hash 方式か外部 script のみのポリシーに寄せる。
Next.js(App Router)
Next.js 13+ では middleware.ts で nonce を生成し、レスポンスヘッダーに CSP を付与する。next/script の nonce prop に同じ値を渡す。
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' https:`,
"style-src 'self' 'unsafe-inline'",
"object-src 'none'",
"frame-ancestors 'none'",
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', csp);
response.headers.set('x-nonce', nonce); // layout で参照
return response;
}
next.config.js の headers() で静的 CSP を返す方法もあるが、nonce が必要な App Router ではミドルウェア生成が前提だ。Third-party script(Google Tag Manager 等)は nonce 非対応のため、タグマネージャ経由のインライン script が Report-Only で大量に違反として出る——許可ドメインの追加か、サーバーサイド計測への移行を検討する。
DevTools に出るよくある CSP 違反
Chrome DevTools の Console タブには、ブロックされたリソースごとに CSP エラーが表示される。本番投入前にローカルでも Report-Only を有効にし、同じメッセージを確認するのが効率的だ。
1. Refused to execute inline script
Refused to execute inline script because it violates the following
Content Security Policy directive: "script-src 'self'".
Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required.
原因: インライン <script> に nonce も hash もない。
対処: nonce を付与するか、外部 .js に移すか、sha256 hash をポリシーに追加する。
2. Refused to load the script … because it violates script-src
Refused to load the script 'https://cdn.jsdelivr.net/npm/chart.js' because it violates
the following Content Security Policy directive: "script-src 'self' 'nonce-...' 'strict-dynamic'".
原因: strict-dynamic 環境で、nonce のない外部 script を直接 <script src> した。
対処: nonce 付きエントリ script から動的ロードするか、移行期はホストを明示的に許可する。
3. Refused to connect to … (connect-src)
Refused to connect to 'https://analytics.example.com/collect' because it violates
the following Content Security Policy directive: "connect-src 'self'".
原因: fetch / XHR / WebSocket の接続先が connect-src にない。
対処: 計測・API・WebSocket のドメインを connect-src に追加する。
4. Refused to apply inline style
Refused to apply inline style because it violates the following
Content Security Policy directive: "style-src 'self'".
原因: style="..." 属性や <style> タグがブロックされた。
対処: CSS を外部ファイル化するか、移行期は style-src 'self' 'unsafe-inline' を許容する(XSS リスクは script より低いがゼロではない)。
5. Framing … violates frame-ancestors
埋め込み側の Console ではなく、親ページの Console に表示されることが多い。
Refused to frame 'https://bank.example.com/login' because it violates
the following Content Security Policy directive: "frame-ancestors 'none'".
原因: 意図どおりの動作(クリックジャッキング防御が効いている)。パートナーへの iframe 配信が必要なら frame-ancestors に相手ドメインを追加する。
Console の CSP エラーを見ながら、Network タブでブロックされたリクエスト(赤色・(blocked:csp))をフィルタすると、どのディレクティブが原因か切り分けやすい。
違反レポート(report-uri)の JSON 例
report-uri(または Report-Only ヘッダー)を設定すると、ポリシー違反時にブラウザが指定 URL へ POST する。ボディは JSON で、おおむね次の構造だ。
リクエストヘッダー:
POST /csp-violations HTTP/1.1
Host: reports.example.com
Content-Type: application/csp-report
リクエストボディ(インライン script 違反の例):
{
"csp-report": {
"document-uri": "https://www.example.com/checkout",
"referrer": "https://www.example.com/cart",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"original-policy": "default-src 'self'; script-src 'self' 'nonce-AbCdEf==' 'strict-dynamic'; report-uri https://reports.example.com/csp-violations",
"disposition": "enforce",
"blocked-uri": "inline",
"line-number": 87,
"column-number": 5,
"source-file": "https://www.example.com/checkout",
"status-code": 200,
"script-sample": "window.dataLayer=window.dataLayer||[]"
}
}
外部 script 違反の例:
{
"csp-report": {
"document-uri": "https://www.example.com/",
"violated-directive": "script-src-elem",
"effective-directive": "script-src-elem",
"blocked-uri": "https://unknown-cdn.evil-tracker.net/pixel.js",
"source-file": "https://www.example.com/",
"disposition": "report"
}
}
disposition が enforce なら強制モードでブロック、report なら Report-Only で観測のみ。blocked-uri が inline のときはインライン script、eval のときは eval() 利用が原因だ。script-sample には違反 script の先頭数十文字が入る(プライバシーに注意し、ログ保管期間を短くする)。
レポート受信エンドポイントは認証不要の POST になるため、レート制限とボディサイズ制限をかけ、ログを集計ダッシュボード(BigQuery・Elasticsearch 等)へ流すのが実務的だ。
よくある質問
CSP と X-XSS-Protection はどちらが重要ですか?
X-XSS-Protection は古いブラウザ向けの反射型 XSS フィルタで、現在は非推奨。モダンな防御は CSP の script-src と入出力のエスケープに集約すべきだ。X-Content-Type-Options: nosniff は MIME スニッフィング防止として引き続き有効。
サードパーティの広告タグは CSP でどう扱いますか?
広告ネットワークはドメインが多く、厳格な CSP とは相性が悪い。script-src に特定ドメインを列挙するか、広告専用のサンドボックスページで緩いポリシーを使う「隔離」戦略がある。どちらにせよ Report-Only で実際の読み込み元を把握してから決める。
開発環境(localhost)では CSP を緩くすべきですか?
connect-src に http://localhost:* や ws://localhost:* を足す、style-src に 'unsafe-inline' を残すなど、環境変数でポリシーを切り替えるのは一般的だ。ただし本番と乖離しすぎると移行時に驚くため、ステージングでは本番同等のポリシーを先に試すのがよい。
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| 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実装 |
まとめ
CSP は XSS とクリックジャッキングをブラウザ側で抑える強力な仕組みだ。実務の要点を再掲する。
- ディレクティブ —
default-srcをベースにscript-src・connect-src・frame-ancestorsを明示する - インライン script —
'unsafe-inline'ではなく nonce または sha256 hash で個別許可する - strict-dynamic — モダンなバンドル/動的 script 追加と両立する
- frame-ancestors —
X-Frame-Optionsの代わりにクリックジャッキングを防ぐ - nginx / Express — 静的サイトは
add_header、SSR はミドルウェアで nonce を生成する - 移行 — Report-Only で段階導入し、DevTools と report-uri で違反を潰してから強制モードへ
多層防御の一環として、XSS 防御ガイド のエスケープ・サニタイズと組み合わせれば、注入されても実行されにくいアプリケーションに近づける。