Laravel で Content-Security-Policy を実装する
Content-Security-Policy(CSP)は、XSS(クロスサイトスクリプティング)の被害をブラウザ側で食い止める最後の防御層。サーバーが「このページで実行してよいスクリプト・読み込んでよいリソースはこれだけ」と宣言し、ブラウザがそれ以外を拒否する。
検証環境
- Laravel 13.x
- PHP 8.5
Laravel で Content-Security-Policy を実装する
Content-Security-Policy(CSP)は、XSS(クロスサイトスクリプティング)の被害をブラウザ側で食い止める最後の防御層。サーバーが「このページで実行してよいスクリプト・読み込んでよいリソースはこれだけ」と宣言し、ブラウザがそれ以外を拒否する。
本記事は Laravel アプリに CSP を正しく導入するための実装ガイド。CodeLift が 3 つの Laravel スターターキット(React / Vue / Livewire)を検証する過程で得た知見を、フォークに依存しない汎用リファレンスとしてまとめた。
実際のフォークでの適用例: スターターキット共通 backend の hardening(CSP 導入)/ Livewire の CSP nonce 化(
'unsafe-inline'撤廃)
CSP は何を防ぐか
XSS が成立すると、攻撃者のスクリプトが正規ページのオリジンで実行される。セッション Cookie の窃取、フォーム改ざん、偽の入力画面の表示——何でもできる。
入力のサニタイズや出力エスケープは第一の防御だが、テンプレートの 1 箇所でも漏れれば破られる。CSP は「たとえスクリプトが注入されても、ブラウザがそれを実行しない」という二の矢。多層防御の発想で、入力対策と CSP は両方やる。
主要ディレクティブ
CSP は Content-Security-Policy レスポンスヘッダで宣言する。実務で効くディレクティブ:
| ディレクティブ | 役割 |
|---|---|
default-src |
他ディレクティブの未指定時のフォールバック。'self' を基本に |
script-src |
スクリプトの出所。最重要。CSP の肝はここ |
style-src |
スタイルシート / インラインスタイルの出所 |
img-src |
画像の出所。data: blob: を許すか判断 |
connect-src |
fetch / XHR / WebSocket の接続先 |
frame-ancestors |
自分を iframe に埋め込めるオリジン(クリックジャッキング対策) |
base-uri |
<base> タグで base URL を書き換えられるか |
form-action |
フォーム送信先 |
object-src |
<object> / <embed>。今はほぼ 'none' でよい |
frame-ancestors 'self' は古い X-Frame-Options の上位互換。両方出しておくと、CSP 非対応の古いブラウザもカバーできる。
script-src — インラインスクリプトの 3 つの許可方法
CSP の難所は「インラインスクリプトをどう許可するか」。<script>...</script> のように HTML に直書きされたスクリプトは、デフォルトの CSP ではブロックされる(これが XSS 対策の本体)。だが正規のインラインスクリプトも一緒にブロックされてしまう。許可方法は 3 つ。
1. 'unsafe-inline' — 楽だが CSP の意味が薄れる
script-src 'self' 'unsafe-inline';
すべてのインラインスクリプトを許可する。設定は一瞬で終わるが、XSS で注入されたスクリプトも許可してしまうので、script-src に関しては CSP がほぼ無力になる。「とりあえず動かす」段階の妥協。
2. ハッシュ — 静的なインラインスクリプト向き
script-src 'self' 'sha256-BASE64HASH';
許可するスクリプトの中身の SHA ハッシュを列挙する。中身が固定なら有効だが、動的に変わるスクリプトには使えず、スクリプトを変更するたびにハッシュ更新が要る。
3. nonce — 動的ページ向きの本命
script-src 'self' 'nonce-RANDOM';
リクエストごとにランダムな文字列(nonce)を生成し、CSP ヘッダと、許可したい <script nonce="RANDOM"> の両方に載せる。ブラウザは nonce が一致するスクリプトだけ実行する。攻撃者は注入時に正しい nonce を知りえない(リクエストごとに変わる)ので、XSS スクリプトは弾かれる。
動的な Laravel アプリでは nonce 方式が基本。以下、nonce 実装を解説する。
Laravel での nonce 実装
ミドルウェアで nonce を生成
リクエストごとに 1 つ nonce を作り、CSP ヘッダに埋め、Blade からも参照できるよう共有する。
class SetSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
// 16 バイトのランダム → base64url(~22 文字)
$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
app()->instance('csp-nonce', $nonce);
\Illuminate\Support\Facades\Vite::useCspNonce($nonce);
\Illuminate\Support\Facades\View::share('cspNonce', $nonce);
$response = $next($request);
$response->headers->set('Content-Security-Policy',
"default-src 'self'; "
."script-src 'self' 'nonce-{$nonce}' 'strict-dynamic'; "
."style-src 'self' 'nonce-{$nonce}'; "
."object-src 'none'; base-uri 'self'; frame-ancestors 'self'"
);
return $response;
}
}
ポイント:
- nonce はリクエストごとに必ず変える。 固定すると攻撃者に予測され、nonce の意味がなくなる
Vite::useCspNonce($nonce)を呼ぶと、Laravel の@viteディレクティブが生成する<script><link>に自動で nonce が付くView::share('cspNonce', ...)で Blade テンプレートから$cspNonceで参照できる
'strict-dynamic' の役割
script-src に 'strict-dynamic' を足すと、「nonce で許可されたスクリプトが動的に読み込んだ子スクリプト」を、子スクリプト側に nonce が無くても許可する。Vite が生成するバンドルはチャンクを動的 import するので、これが無いと一部のチャンクがブロックされる。
Blade のインラインスクリプトに nonce を付ける
テンプレートに手書きの <script> <style> があれば nonce 属性を足す。
<script nonce="{{ $cspNonce ?? '' }}">
/* ... */
</script>
フロントエンド構成別の注意点
CSP の難易度はフロントの作りで変わる。
Blade / Livewire — nonce 化しやすい
Livewire はコンポーネントの初期状態を HTML 要素の wire:* 属性で持ち、クライアント JS は外部ファイルで読む。初期 state を inline <script> に埋めないので、'unsafe-inline' 無しの nonce ベース CSP に素直に移行できる。Livewire / Flux の各ディレクティブも nonce オプションを受け取れる。実例は Livewire CSP nonce 化記事。
Inertia(React / Vue)— inline props がネック
Inertia は初回 HTML に、ページの初期 props を data-page 属性 + ブートストラップ部分として埋め込む。この部分が 'unsafe-inline' を要求しがちで、完全に外すには HandleInertiaRequests やレスポンスレンダリングへの改造、または Inertia SSR への移行が要る。最初は 'unsafe-inline' を許容し、段階的に締める判断もあり。
report-only で安全にロールアウトする
いきなり enforce すると、見落としたインラインスクリプトでページが壊れる。Content-Security-Policy-Report-Only ヘッダを使うと、違反を検出するがブロックはしない。
Content-Security-Policy-Report-Only: default-src 'self'; ...; report-uri /csp-report
report-uri / report-to に違反レポートの送信先を指定し、本番トラフィックでしばらく観測 → 違反ゼロを確認 → enforce 版に切り替える、という段階移行が安全。
CSP のテスト
CSP ヘッダの内容はテストで固定できる。
public function test_production_csp_forbids_unsafe_inline(): void
{
config()->set('app.env', 'production');
$response = $this->get('/');
$csp = $response->headers->get('Content-Security-Policy');
$this->assertStringNotContainsString("'unsafe-inline'", $csp);
$this->assertMatchesRegularExpression("/script-src [^;]*'nonce-/", $csp);
}
nonce がリクエストごとに変わることも検証しておくとよい(2 リクエストで値が異なる)。
開発環境での扱い
CSP(特に厳格なもの)は Vite の dev サーバや HMR と衝突しやすい。Strict-Transport-Security も localhost に焼き付くと厄介。CSP と HSTS は本番環境のみで出し、ローカル / テストでは出さないのが実務的。
if (app()->isProduction()) {
$response->headers->set('Content-Security-Policy', $policy);
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
まとめ
- CSP は XSS の二の矢。入力対策と併用する
script-srcが肝。動的 Laravel アプリでは nonce 方式が基本- ミドルウェアでリクエストごとに nonce 生成 →
Vite::useCspNonce+View::share - Livewire は nonce 化しやすい / Inertia は inline props がネック
Content-Security-Policy-Report-Onlyで段階ロールアウト- CSP / HSTS は本番のみ
関連記事
- Laravel スターターキット共通 backend を本番化する 6 つの改修 — CSP を含むセキュリティヘッダ導入
- Laravel + Livewire Starter Kit の CSP を nonce 化 —
'unsafe-inline'撤廃の実例
ライセンス / 注記
本記事はガイドであり特定の改良版フォークに紐づかない。コード例は説明用の最小形。検証環境は Laravel 13.x / PHP 8.5。
関連記事
- Laravel スターターキット共通 backend を本番化する 6 つの改修 Laravel 公式のスターターキット(React / Vue / Livewire)は、フロントエンド層こそ違うものの backend は同一の Laravel + Fortify コード を共有している。そのため、本番運用に向けた hardening も 3 つで共通する部分が大半を占める。本記事はその共通部分 — CodeLift が 3 つの fork すべてに適用した backend 改修 — を 1 か所にまとめた pillar(土台)記事である。
- Laravel + Livewire Starter Kit の CSP を nonce 化 Livewire Starter Kit の Docker 検証・改良版フォークで組んだ SetSecurityHeaders middleware は、Content-Security-Policy の script-src / style-src に 'unsafe-inline' を残していた。これは React / Vue 変種と揃えるための暫定措置で、Livewire 変種では 実際には不要 と前回の記事でも予告した通り。本記事ではその予告を回収する作業ログと、最…