- 技術 💻
CSP設定(Content-Security-Policy)|nginx段階導入
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期連続の黒字決算。