{} CodeLift

Laravel で Content-Security-Policy を実装する

Content-Security-Policy(CSP)は、XSS(クロスサイトスクリプティング)の被害をブラウザ側で食い止める最後の防御層。サーバーが「このページで実行してよいスクリプト・読み込んでよいリソースはこれだけ」と宣言し、ブラウザがそれ以外を拒否する。

公開 2026-04-19 検証 2026-04-19 更新 2026-05-18

検証環境

  • 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 13.x / PHP 8.5。

関連記事