Laravel + Livewire Starter Kit の CSP を nonce 化
Livewire Starter Kit の Docker 検証・改良版フォークで組んだ SetSecurityHeaders middleware は、Content-Security-Policy の script-src / style-src に 'unsafe-inline' を残していた。これは React / Vue 変種と揃えるための暫定措置で、Livewire 変種では 実際には不要 と前回の記事でも予告した通り。本記事ではその予告を回収する作業ログと、最…
検証環境
- PHP 8.5.5
- Laravel 13.x
- Composer 2.9.7
- Node 22.22.2
- npm 10.9.7
- Frontend Livewire v4 + Flux + Alpine
- Base branch improvements
- Database SQLite (tests)
- OS Docker Desktop (php:8.5-cli-bookworm)
Laravel + Livewire Starter Kit の CSP を nonce 化
Livewire Starter Kit の Docker 検証・改良版フォークで組んだ SetSecurityHeaders middleware は、Content-Security-Policy の script-src / style-src に 'unsafe-inline' を残していた。これは React / Vue 変種と揃えるための暫定措置で、Livewire 変種では 実際には不要 と前回の記事でも予告した通り。本記事ではその予告を回収する作業ログと、最終的に届いた CSP ポリシーを記録する。
deliverable: csp-nonce ブランチ に 1 commit(+67 行)。テスト 37 → 39 passed (92 → 102 assertions)。
姉妹記事 → Laravel + Livewire Starter Kit を Docker で検証して改良した fork を公開
なぜ Livewire では nonce 化できるのか
React / Vue の Inertia 実装は、初期 props を HTML に埋め込むときに次のような inline script を吐く:
<div id="app" data-page="{...encoded props...}"></div>
<!-- かつて: -->
<script>window.__INERTIA__ = {...};</script>
この inline script を許容するために 'unsafe-inline' を CSP に残すか、nonce を各 script タグに埋め込む必要がある。nonce 方式にするには HandleInertiaRequests を改造して nonce を通す実装が必要で、CodeLift の React / Vue 記事では「その改修はスコープ外」として保留した。
Livewire は そもそも初期状態を inline script で出力しない:
- クライアント JS(
livewire.js,flux.js)は外部<script src="...">で読み込まれる - 各コンポーネントの状態は HTML 要素の
wire:*属性で保持される - 更新は
/livewire/updateへの fetch リクエストで行われる
つまり Livewire が HTML に吐く要素のうち、'unsafe-inline' を必要とするのは Livewire 自身が注入する小さな起動 script / style だけで、これは Livewire 自体が nonce option を受け取れる作りになっている。
実装を覗くと vendor/livewire/livewire/src/Mechanisms/FrontendAssets/FrontendAssets.php にこういう分岐がある:
protected static function nonce($options = [])
{
$nonce = $options['nonce'] ?? Vite::cspNonce();
return $nonce ? "nonce=\"{$nonce}\"" : '';
}
Vite::cspNonce() を fallback として参照しているので、Vite::useCspNonce($nonce) を middleware で呼ぶだけで Livewire・Vite の両方が同じ nonce を拾う。追加配線は最小で済む。
実装
1 ファイル書き換え + 7 テンプレート書き換え + テスト追加で終わる。
SetSecurityHeaders middleware を nonce 生成器に格上げ
handle() の先頭で 16 バイトのランダムバイト列から base64url の nonce を作り、
- コンテナに
csp-nonceとして bind Vite::useCspNonce($nonce)を呼ぶ →@vite(...)と Livewire の自動注入 asset に波及view()->share('cspNonce', $nonce)→ Blade で$cspNonceとして参照可能
$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');
app()->instance('csp-nonce', $nonce);
Vite::useCspNonce($nonce);
View::share('cspNonce', $nonce);
CSP ポリシーを nonce + strict-dynamic に
'unsafe-inline' を削除、'nonce-{n}' と 'strict-dynamic' を足す:
default-src 'self';
script-src 'self' 'nonce-XYZ' 'strict-dynamic';
style-src 'self' 'nonce-XYZ' https://fonts.bunny.net;
img-src 'self' data: blob:;
font-src 'self' data: https://fonts.bunny.net;
connect-src 'self';
frame-ancestors 'self';
base-uri 'self';
form-action 'self';
object-src 'none';
'strict-dynamic' は nonce が通った script が動的に import した子 script を、子 script 側の nonce なしで許可する ディレクティブ。Vite が bundle chunk を動的 import するので必要。
fonts.bunny.net は Starter Kit 既定のフォントホストなので allow-list に追加(Bunny Fonts / Google Fonts いずれも同じパターン)。
Flux ディレクティブに nonce を渡す
@fluxAppearance と @fluxScripts は nonce オプションを受け取れるので、全レイアウトで明示:
@fluxAppearance(['nonce' => $cspNonce ?? null])
@fluxScripts(['nonce' => $cspNonce ?? null])
対象ファイル:
resources/views/partials/head.blade.phpresources/views/layouts/app/header.blade.phpresources/views/layouts/app/sidebar.blade.phpresources/views/layouts/auth/card.blade.phpresources/views/layouts/auth/simple.blade.phpresources/views/layouts/auth/split.blade.php
既存 inline <style> にも nonce
welcome.blade.php にだけ巨大な inline <style> があるので属性追加:
-<style>
+<style nonce="{{ $cspNonce ?? '' }}">
テスト
tests/Feature/SecurityHeadersTest.php に 2 件追加:
$cspNonceが view に共有され、レスポンス間で値が変わる(nonce の一度性)- 本番環境相当で CSP header を取り、
'unsafe-inline'が含まれない /script-srcとstyle-srcにnonce-...が入る /'strict-dynamic'が含まれる、を同時に検証
public function test_production_csp_includes_nonce_and_forbids_unsafe_inline(): void
{
config()->set('app.env', 'production');
$this->app->detectEnvironment(fn () => 'production');
$response = $this->get('/login');
$csp = $response->headers->get('Content-Security-Policy');
$this->assertStringNotContainsString("'unsafe-inline'", $csp);
$this->assertMatchesRegularExpression("/script-src [^;]*'nonce-[A-Za-z0-9_-]+'/", $csp);
$this->assertMatchesRegularExpression("/style-src [^;]*'nonce-[A-Za-z0-9_-]+'/", $csp);
$this->assertStringContainsString("'strict-dynamic'", $csp);
}
before / after
| 観点 | improvements ブランチ |
csp-nonce ブランチ |
|---|---|---|
script-src |
'self' 'unsafe-inline' |
'self' 'nonce-{req}' 'strict-dynamic' |
style-src |
'self' 'unsafe-inline' |
'self' 'nonce-{req}' https://fonts.bunny.net |
| nonce の経路 | — | middleware → container + Vite + View share |
| Flux directive | @fluxAppearance / @fluxScripts |
@fluxAppearance(['nonce'=>$cspNonce]) / @fluxScripts(['nonce'=>$cspNonce]) |
$cspNonce Blade 変数 |
未定義 | 全ビューから参照可能 |
| テスト | 2 | 4(+ nonce 一度性 / nonce 含有) |
React / Vue 変種への適用可否
そのままは無理。React / Vue 変種では Inertia が初期 props を inline <script> で吐くので、nonce 化するには:
- middleware で nonce を生成するのは同じ
- 追加で
HandleInertiaRequestsを改造して、Inertia がレンダリングする inline script タグに nonce を埋める - あるいは Inertia の server-side rendering(SSR)に切り替えて inline script を外部化
(2) は Inertia 本体へのパッチまたは decorator 実装が必要で、scope としては「CodeLift の別記事」レベル。興味があれば follow-up を書く。
副作用 / 注意点
'strict-dynamic'は 古いブラウザでフォールバック動作が違う(Safari 15.3 以前、古い Chromium 系)。ただし本件のような同一オリジン Vite bundle なら問題は起きにくい。厳密性が要る環境では意図的に外してscript-src 'self' 'nonce-...'だけにしてもよい- CSP 違反を観測したい場合は
report-uri/report-toを付けてContent-Security-Policy-Report-Onlyで dry-run する方法もある。本 commit には入れていない(運用側で追加しやすいようポリシー本体だけに絞った) - Livewire の内部 asset route(
/livewire/livewire.jsなど)は同じオリジンで配信されるのでscript-src 'self'で十分、追加の allow-list は要らない
再現と取り込み
git clone https://github.com/codelift-dev/livewire-starter-kit.git
cd livewire-starter-kit
git checkout csp-nonce
docker compose -f codelift/docker-compose.yml build
docker compose -f codelift/docker-compose.yml run --rm app
improvements から csp-nonce の差分だけ見たい場合:
git diff improvements csp-nonce -- . ':!codelift'
ライセンス / 法務
- 元サンプル: MIT(Laravel LLC / 開発チーム)
- 改良版: MIT(CodeLift / JIT 株式会社)
検証結果は検証日時点のもの。upstream 更新により状況は変わる可能性がある。