Content Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策

CSP セキュリティ XSS HTTPヘッダー Web
結論

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-srcscript-src を固め、必要に応じて style-srcconnect-srcimg-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 は最後の手段

移行期にだけ '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 タグでは frame-ancestors は効かない

<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.tscrypto.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 ドメインなど想定外の読み込み元で本番が壊れる。安全な移行は次の流れだ。

1

DevTools Network で script / style / img / font / connect の読み込み元を棚卸しする

2

Content-Security-Policy-Report-Only を全トラフィックに配信し、違反をブロックせず記録する

3

report-uri または report-to でレポートを集約し、許可漏れドメインをリスト化する

4

script-src に nonce + strict-dynamic を導入し、unsafe-inline を段階的に外す

5

frame-ancestors・object-src 'none'・base-uri を追加して攻撃面を狭める

6

問題がなくなったら 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/scriptnonce 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.jsheaders() で静的 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 に相手ドメインを追加する。

Network タブとの併用

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"
  }
}

dispositionenforce なら強制モードでブロック、report なら Report-Only で観測のみ。blocked-uriinline のときはインライン 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-srchttp://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-srcconnect-srcframe-ancestors を明示する
  • インライン script'unsafe-inline' ではなく nonce または sha256 hash で個別許可する
  • strict-dynamic — モダンなバンドル/動的 script 追加と両立する
  • frame-ancestorsX-Frame-Options の代わりにクリックジャッキングを防ぐ
  • nginx / Express — 静的サイトは add_header、SSR はミドルウェアで nonce を生成する
  • 移行Report-Only で段階導入し、DevTools と report-uri で違反を潰してから強制モードへ

多層防御の一環として、XSS 防御ガイド のエスケープ・サニタイズと組み合わせれば、注入されても実行されにくいアプリケーションに近づける。