Content-Security-Policy(CSP)とは、ページが読み込んでよいスクリプト・スタイル・画像などの取得元をブラウザに指示するHTTPレスポンスヘッダーです。 許可していないスクリプトをブラウザが止めるため、XSSが起きたときの被害を小さくできます。

ただしいきなり適用すると、インラインスクリプトや外部画像まで止まることがあります。この記事では、ローカルの nginx で Report-Only → 違反レポート受信 → 修正 → 本番適用 を実際に行い、Chromium のメッセージ、受信したJSON、nonce・hashでの許可までを実行結果つきで解説します。

検証環境: nginx 1.24.0、Node.js 22、Playwright 1.56.1 + Chromium(HeadlessChrome 141.0.7390.37)、curl。

Content-Security-Policyの早見表

ディレクティブ 制御するもの 設定例
default-src 取得系ディレクティブの既定値 'self'
script-src JavaScript 'self' 'nonce-…' 'strict-dynamic'
style-src CSS 'self'
img-src 画像 'self' data:
connect-src fetch・XHR・WebSocket 'self' https://api.example.net
font-src Webフォント 'self'
frame-src 埋め込む iframe https://video.example.net
object-src <object> <embed> 'none'(推奨)
frame-ancestors 自分のページを埋め込める親 'self' / 'none'
base-uri <base> タグのURL 'self' / 'none'
form-action フォームの送信先 'self'
upgrade-insecure-requests http の読み込みを https に変換 値なし
report-to / report-uri 違反レポートの送信先(新/旧) csp-endpoint / /csp-report
ソース値 意味
'self' 同じオリジン(スキーム・ホスト・ポート)
'unsafe-inline' インラインをすべて許可(効果が大きく下がる)
'unsafe-eval' eval() や new Function() を許可
'nonce-ランダム値' 同じ nonce 属性のインラインだけ許可
'sha256-ハッシュ' ハッシュが一致するインラインだけ許可
'strict-dynamic' nonce・hash で許可したスクリプトが追加するスクリプトも許可(ホスト指定は無視)

frame-ancestors・base-uri・form-action は default-src を引き継がないため、個別に書きます(MDNの記載)。また、Content-Security-Policy はブロックして報告、Content-Security-Policy-Report-Only は報告だけします。

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSP設定(Content-Security-Policy)|nginx段階導入」

検証用のページとnginx設定

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>CSP demo</title>
<link rel="stylesheet" href="/app.css">
<style>h1 { color: teal; }</style>
</head>
<body>
<h1>タスク一覧</h1>
<p id="msg">loading</p>
<button id="btn" onclick="alert('clicked')">追加</button>
<img src="https://cdn.example.net/logo.png" alt="logo">
<script>document.getElementById('msg').textContent = 'inline ok';</script>
<script src="/app.js"></script>
</body>
</html>

/app.js は console.log('app.js loaded'); だけです。<style>、onclick、外部画像、インライン <script> がCSPの対象になります。

手順1:Report-Onlyで違反を集める

nginx の設定

server {
    listen 127.0.0.1:7400;
    add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; form-action 'self'; report-uri /csp-report" always;

    location /csp-report {
        proxy_pass http://127.0.0.1:7401;   # 下のNodeサーバーへ
    }
}

always でエラー応答(404・500など)にも付きます。

違反レポートを受け取るサーバー(Node.js)

// report-server.mjs(Node.js 22、外部パッケージ不要)
import http from 'node:http'

http.createServer((req, res) => {
  if (req.method === 'POST' && req.url.startsWith('/csp-report')) {
    let body = ''
    req.on('data', c => (body += c))
    req.on('end', () => {
      console.log(JSON.stringify({ type: req.headers['content-type'], body: JSON.parse(body) }))
      res.writeHead(204).end()
    })
    return
  }
  res.writeHead(404).end()
}).listen(7401, '127.0.0.1', () => console.log('report server on :7401'))

Chromium で開いた結果

Playwright 1.56 の page.on('console', …) でコンソールを記録し、クリック前後の #msg と h1 の色も取得しました。

Report-Only のときに表示されたメッセージ(実文、長い行は後半を省略):

[console.info] [Report Only] Refused to apply inline style because it violates the following Content Security Policy directive: "style-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-nFO+7jRf1ZIlUMcloyjb5y7kpUuLNCRn4FtqcZbO2I0='), or a nonce ('nonce-...') is required …
[console.info] [Report Only] Refused to load the image 'https://cdn.example.net/logo.png' because it violates the following Content Security Policy directive: "img-src 'self'".
[console.info] [Report Only] Refused to execute inline script because it violates … a hash ('sha256-iKqX6nc6oC/j7RJjtddZOhxxS4bnEjpfHfmFc5vi3xM='), or a nonce …
[console.log] app.js loaded
[console.info] [Report Only] Refused to execute inline event handler because it violates … Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present.

[Report Only] 付きの info で表示され、ブロックはされません(#msg は inline ok、alert も表示)。許可に必要なハッシュ値までメッセージに表示されています。

受信したレポート(JSON)

受信サーバーに4件届いたうちの、画像の1件です。

{
    "type": "application/csp-report",
    "body": {
        "csp-report": {
            "document-uri": "http://127.0.0.1:7400/",
            "effective-directive": "img-src",
            "original-policy": "default-src 'self'; script-src 'self'; …(設定した方針全体)",
            "disposition": "report",
            "blocked-uri": "https://cdn.example.net/logo.png",
            "line-number": 13,
            "source-file": "http://127.0.0.1:7400/",
            "status-code": 200
        }
    }
}

他の3件は effective-directive が style-src-elem(7行目の <style>)、script-src-elem(14行目のインライン <script>)、script-src-attr(12行目の onclick)でした。disposition は Report-Only なら report、適用中なら enforce です。<script> 要素(-elem)と onclick などの属性(-attr)も区別されます。

report-to と report-uri の違い

report-uri は非推奨で、後継は report-to(Reporting API)です。report-to は Reporting-Endpoints ヘッダーで送信先に名前を付け、レポートを application/reports+json でまとめて遅れて送ります。

設定は add_header Reporting-Endpoints 'csp-endpoint="https://example.com/csp-report"' always; とし、CSPに report-uri /csp-report; report-to csp-endpoint を並べます(MDNは両方の指定を案内)。両方を指定すると Chromium 141 は report-uri に送信しませんでした(仕様どおり report-to 優先)。report-to 経由のレポートはこの検証環境(ローカルHTTP・自己署名HTTPS、ヘッドレス)では70秒待っても届かず、受信を確認できたのは report-uri のみです。

手順2:違反を修正する

<style> とインライン <script> は外部ファイル化か nonce / hash、外部画像は img-src に追加か自サイトへ、onclick は addEventListener に書き換えます(イベント属性は 'unsafe-hashes' がないと hash で許可できない)。今回は onclick を外部ファイル /app2.js の addEventListener('click', …)(#msg を clicked にする処理)に移しました。

手順3:本番適用(Content-Security-Policy)

修正前のページのままヘッダー名を Content-Security-Policy に変えた結果です。

[console.error] Refused to apply inline style because it violates … "style-src 'self'". …
(画像・インラインスクリプト・イベントハンドラーも同様に console.error)
[console.log] app.js loaded
#msg (before click) = loading
#msg (after click)  = loading
h1 color = rgb(0, 0, 0)

インラインのスクリプト・ボタン・スタイルが止まりました(h1 が teal から黒に)。'self' の app.js は動いています。Report-Onlyで違反0件にしてから切り替えます。

インラインスクリプトを許可する:hash方式とnonce方式

hash方式(内容が変わらないインライン向け)

メッセージに表示されたハッシュは、自分でも計算できます。

printf "%s" "document.getElementById('msg').textContent = 'inline ok';" | openssl dgst -sha256 -binary | base64
# 出力:iKqX6nc6oC/j7RJjtddZOhxxS4bnEjpfHfmFc5vi3xM=(Chromiumの表示と一致)

<script> と </script> の間を、空白や改行も含めてそのままハッシュします。

location = /hash.html {
    add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-iKqX6nc6oC/j7RJjtddZOhxxS4bnEjpfHfmFc5vi3xM='; style-src 'self' 'sha256-nFO+7jRf1ZIlUMcloyjb5y7kpUuLNCRn4FtqcZbO2I0='; object-src 'none'; base-uri 'self'; frame-ancestors 'self'" always;
}

一致するスクリプトと、一致しない別のインラインスクリプトを置いたページの結果:

[console.error] Refused to execute inline script because it violates … "script-src 'self' 'sha256-iKqX…'". Either the 'unsafe-inline' keyword, a hash ('sha256-tddR0ZuDDjZDbjxLQVQEn9th0HJwxhIzuDAWIzKcGNM='), …
[console.log] app2.js loaded
#msg (before click) = inline ok
#msg (after click)  = clicked
h1 color = rgb(0, 128, 128)

一致したスクリプトとスタイルは実行・適用され、一致しない方だけがブロックされました。

nonce方式(リクエストごとにランダム値)

nonce はレスポンスごとに新しいランダム値をヘッダーとHTMLの両方に入れます。nginx 単体なら、リクエストごとの $request_id(16バイトの乱数の16進数)と sub_filter で実現できます。

<style nonce="__CSP_NONCE__">h1 { color: teal; }</style>
…
<script nonce="__CSP_NONCE__">
  document.getElementById('msg').textContent = 'nonce ok';
  const s = document.createElement('script');
  s.src = '/app2.js';
  document.body.appendChild(s);
</script>
<script>console.log('no nonce');</script>
location = /nonce.html {
    add_header Content-Security-Policy "script-src 'nonce-$request_id' 'strict-dynamic'; style-src 'self' 'nonce-$request_id'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'" always;
    add_header Cache-Control "no-store" always;   # nonce入りHTMLをキャッシュさせない
    sub_filter_once off;
    sub_filter '__CSP_NONCE__' $request_id;
}

curl で確認すると、ヘッダーとHTMLに同じ値(例:c7e49a28fa6e2f645dd613330088c726)が入り、リクエストごとに値が変わりました。

Chromium での結果:

[console.error] Refused to execute inline script because it violates … "script-src 'nonce-b9302b9d5ddd898bc6e394fb375bdd44' 'strict-dynamic'". …
[console.log] app2.js loaded
#msg (before click) = nonce ok
#msg (after click)  = clicked
h1 color = rgb(0, 128, 128)

nonce付きは動き、nonceのない <script> だけがブロックされました。 'strict-dynamic' により、nonce付きスクリプトが追加した /app2.js も読み込まれています('self' なしでも動く)。

方式 向いている場面 注意点
外部ファイル化 基本はこれ 変更が必要
hash 静的HTML、内容が固定のインライン 内容が1文字変わるとハッシュも変更
nonce サーバーでHTMLを生成するサイト 毎回新しい値、HTMLをキャッシュしない
'unsafe-inline' 一時的な回避 nonce / hash と併記すると対応ブラウザでは無視される

nginxの落とし穴:locationにadd_headerを書くと上のヘッダーが消える

server にセキュリティヘッダーをまとめ、/hash.html の location だけ CSP を上書きしたときの curl -I です(/ では Referrer-Policy など7種すべてを確認)。

$ curl -sI http://127.0.0.1:7400/ | grep -E '^(Content-Security|Strict|X-Content)'
Content-Security-Policy: default-src 'self'; script-src 'self'; …(下の本番用設定と同じ)
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Content-Type-Options: nosniff

$ curl -sI http://127.0.0.1:7400/hash.html | grep -E '^(Content-Security|Strict|X-Content)'
Content-Security-Policy: default-src 'self'; script-src 'self' 'sha256-iKqX…'; …

/hash.html では HSTS などが全部消えました。 add_header は「今のレベルに add_header がないときだけ上から継承される」仕様だからです。対策は次のとおりです。

  • 共通ヘッダーを security-headers.conf にまとめ、各 location で include し直す
  • nginx 1.29.3 以降は add_header_inherit merge; も使える(公式ドキュメントに基づく、未検証)

本番用の設定例(コピペ用)

nginx

# /etc/nginx/snippets/security-headers.conf
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests; report-uri /csp-report" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Cross-Origin-Opener-Policy "same-origin" always;

server で include snippets/security-headers.conf; し、add_header を書いた location でも再度 include します(nginx 1.24.0 と curl -I で確認済み)。

Apache(.htaccess または VirtualHost)

<IfModule mod_headers.c>
    Header always set Content-Security-Policy "default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"
    Header always set X-Content-Type-Options "nosniff"
    Header always set Referrer-Policy "strict-origin-when-cross-origin"
    Header always set X-Frame-Options "SAMEORIGIN"
</IfModule>

mod_headers の Header always set を使います(公式ドキュメントに基づく、未実行)。.htaccess では AllowOverride FileInfo が必要です。

WordPress(functions.php)

<?php
// テーマの functions.php(子テーマ推奨)
add_action( 'send_headers', function () {
    if ( is_admin() ) return; // 管理画面は対象外に
    header( "Content-Security-Policy-Report-Only: default-src 'self'; img-src 'self' data: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'self'; report-uri /csp-report" );
    header( 'X-Content-Type-Options: nosniff' );
    header( 'Referrer-Policy: strict-origin-when-cross-origin' );
} );

send_headers アクションの例です(未実行)。テーマやプラグインのインラインが多いためまず Report-Onlyにします。wp_get_inline_script_tag() で出力されるインラインスクリプトには、wp_inline_script_attributes フィルター(5.7で追加)で nonce 属性を付けられます。

他のセキュリティヘッダー一覧

ヘッダー 推奨値の例 役割
HSTS max-age=31536000; includeSubDomains 以後は https だけで接続(HTTPS時のみ有効)
X-Content-Type-Options nosniff Content-Type の推測をさせない
Referrer-Policy strict-origin-when-cross-origin 他サイトへの Referer をオリジンだけに
Permissions-Policy camera=(), geolocation=() 機能を無効化
X-Frame-Options SAMEORIGIN 埋め込み対策。後継は frame-ancestors で、両方あればそちらが優先
COOP / COEP / CORP same-origin / require-corp / same-origin 別オリジンとの分離(COEP は SharedArrayBuffer 利用時など)

HSTS は max-age の間 http に戻せないため短い値から試し、COEP は必要なときだけ設定します。

よくある質問

Q. <meta http-equiv="Content-Security-Policy"> でも設定できますか? A. できますが、frame-ancestors・report-uri・sandbox と Report-Only は meta では使えません。 段階導入するならHTTPヘッダーで設定します。

Q. Google アナリティクスなど外部タグを使っているときは? A. nonce と 'strict-dynamic' を組み合わせると、nonce付きスクリプトが追加する外部スクリプトも許可されます。送信先は connect-src などに追加します。

Q. 方針の弱い点を確認する方法は? A. コンソールの Refused to … と Report-Only のレポートを一定期間集め、Google の CSP Evaluator などで方針を点検します。

まとめ

  • CSPは「読み込んでよい取得元」を伝えるヘッダーで、XSSの被害を小さくする
  • Report-Only → レポート確認 → 修正 → 本番適用で画面を崩さず導入できる
  • Chromium は Report-Only を info、適用時を error で表示し、必要なハッシュ値も示す
  • インラインは 外部化が基本、固定なら hash、動的なら nonce+'strict-dynamic'
  • nginx は location に add_header を書くと上位のヘッダーが消える
  • frame-ancestors・base-uri・form-action は個別に指定する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「CSP設定(Content-Security-Policy)|nginx段階導入」

株式会社RJCでは、セキュリティヘッダーのような「見えないところの品質」も、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?