asignua / filament-csp-nonce
Per-request CSP nonce, a Content-Security-Policy header and violation reports for Filament panels.
Requires
- php: ^8.3
- filament/filament: ^5.0
- illuminate/contracts: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.16
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
- phpunit/phpunit: ^11.5|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A per-request CSP nonce, a ready Content-Security-Policy header and a violation report endpoint for
Filament panels, without overriding a single Filament view.
Without nonces, a Content-Security-Policy for Filament needs 'unsafe-inline' for scripts, which defeats the point
(filamentphp/filament#7032,
#8329). Filament and Livewire already print the nonce on
their asset tags when Laravel's Vite::useCspNonce() is set, but a handful of Filament's own inline <script> and
<style> tags have none. This package sets the nonce, closes those gaps and sends the header.
Read What this does and does not protect before relying on it. Filament needs
'unsafe-eval'. That is a property of Alpine, not of this package.
- Screenshots
- Requirements
- Installation
- Usage
- What this does and does not protect
- Configuration
- Gotchas
- Uninstalling
- Translations
- AI agents
- Testing
Screenshots
The Content-Security-Policy header the plugin sends for a panel page (default strict-dynamic preset, captured from the Testbench workbench; the nonce is different on every request). All 18 <script>/<style> tags of the list page carry it.
Requirements
- PHP 8.3+, Laravel 12 or 13, Filament 5 (tested on 5.9, Livewire 4.4).
Installation
composer require asignua/filament-csp-nonce php artisan vendor:publish --tag=csp-nonce-config # optional php artisan vendor:publish --tag=csp-nonce-migrations # only for database report storage php artisan view:clear # once: the Blade rewriter works at compile time
No assets to publish (filament:assets is not needed: the package ships no CSS or JS).
Usage
use Asignua\FilamentCspNonce\CspNoncePlugin; $panel->plugin( CspNoncePlugin::make() ->reportOnly(), // start here, look at the reports, then remove it );
Everything is optional:
CspNoncePlugin::make() ->preset(Preset::Compatible) // or Preset::StrictDynamic (default) ->directives([ // merged over the preset; null removes a directive 'connect-src' => ["'self'", 'wss://ws.example.com'], 'img-src' => ["'self'", 'data:', 'https://cdn.example.com'], ]) ->allowInlineStyles() // ColorPicker, CodeEditor (see Gotchas) ->policy(fn (CspPolicy $preset) => $preset->directive('frame-src', ["'self'", 'https://www.youtube.com']));
Outside panels (your public pages), use the middleware alias and the helpers:
Route::middleware('csp.nonce')->group(...); // csp.nonce:admin uses the policy of panel "admin"
<script @cspNonce>...</script> {{-- nonce="..." --}} {{ csp_nonce() }} {{-- the raw value, or null --}}
Presets
| Preset | script-src |
style-src |
|---|---|---|
filament-strict-dynamic (default) |
'nonce-…' 'strict-dynamic' 'unsafe-eval' |
'self' 'nonce-…' + style-src-attr 'unsafe-inline' |
filament-compatible |
'self' 'nonce-…' 'unsafe-eval' (same-origin scripts keep working, e.g. third-party plugin assets) |
'self' 'unsafe-inline' |
Both add default-src 'self', object-src 'none', base-uri 'self', form-action 'self',
frame-ancestors 'self', img-src 'self' data: blob: https:, font-src 'self' data:, connect-src 'self',
media-src 'self' blob: data:, worker-src 'self' blob:, report-uri and report-to.
->directives() and ->allowInlineStyles() accumulate in any order; a later value for the same directive wins. A
CspPolicy instance passed to ->policy() is cloned per request, so it can be shared between panels.
Violation reports
POST /csp/report (no session, no CSRF, throttled) accepts both report-uri and Reporting API bodies, strips query
strings, caps the payload at 16 KB and stores per report.storage: log (default), database (publish the migration;
identical violations fold into one row with a hit counter; prune with php artisan csp:prune) or null.
The endpoint is public and unauthenticated, so every report field is attacker-controlled. The limits below protect storage (the table, the log, the cache), not report completeness: a flood of forged reports can use up the per-minute budget or fill the row cap and crowd out genuine violations, so treat a report-only rollout as a hint, not as proof that nothing breaks.
- reports about a document on another host are dropped (allowed: the request host, the host of
app.url,report.allowed_hosts). This filters misrouted reports, it is not a defence: the request host comes from theHostheader unless the app trusts only known hosts (TrustHosts), and random paths on the real host pass anyway; - at most
report.max_new_per_minute(100) new violations are stored or logged per minute across all clients; repeats of a known violation only bump its counter (database) or are logged once per hour (log); - the table holds at most
report.max_rows(10 000) rows; report.throttle(300,1) limits requests per IP. Firefox POSTs once per violation, so a busy page under report-only sends many, and behind a proxy withoutTrustProxiesall users share one IP: raise it if reports go missing (429). A Reporting API batch is cut at 20 entries.- with
databasestoragecsp:pruneis registered in the scheduler (report.prune_schedule,daily: a parameterless frequency method such ashourly/weekly, or a cron expression such as15 3 * * *; anything else is logged as a warning and not scheduled;nullturns it off). You still needschedule:runin cron.
There is no UI for stored violations: query the table (or build a Filament resource on
Asignua\FilamentCspNonce\Models\CspViolation).
What this does and does not protect
What you get with the default preset (measured in a real browser against Filament 5.9, see FEASIBILITY.md):
- No inline script runs unless it carries this request's nonce: injected
<script>tags,onerror=-style handlers andjavascript:URLs are blocked. No host allow-list to bypass (strict-dynamic). object-src,base-uri,form-actionandframe-ancestorslocked down.- Style elements are nonce-only; violation reports tell you what else a page needs.
What you do not get:
'unsafe-eval'stays. Alpine compiles everyx-*expression withnew Function, and Filament's templates use full JavaScript in them. Livewire 4's CSP-safe build (livewire.csp_safe) was tried: Alpine's CSP parser rejects Filament's expressions and the panel stops working. Consequence: an attacker who can inject HTML into a region Alpine initialises can still run JavaScript via anx-init/x-on:*attribute. Keep escaping output; treat this as defence in depth, not as a replacement for it.style="..."attributes stay allowed (style-src-attr 'unsafe-inline'): Filament renders up to ~20 per page and CSS cannot be nonced on attributes.
Configuration
config/csp-nonce.php: enabled (env CSP_ENABLED), report_only (CSP_REPORT_ONLY), preset, directives,
report.* (enabled, path, throttle, storage, log_channel, table, retention_days, allowed_hosts,
max_new_per_minute, max_rows, prune_schedule) and blade.*
(rewrite, packages fnmatch patterns of Composer packages whose templates get nonces, paths).
Third-party Filament plugins that print bare <script>/<style> tags: add their package to blade.packages
(['filament/*', 'awcodes/*']) and run php artisan view:clear.
Gotchas
php artisan view:clearafter installing or changingblade.*. The rewriter runs when a view is compiled; already-compiled views keep their old tags.- The rewriter only touches template source, never rendered output, so HTML injected by a user does not receive a
nonce. Tags inside
@verbatim,@php ... @endphpand<?php ... ?>are skipped (Blade does not compile them). A tag that already prints a nonce is left alone: anonce/:nonce/x-bind:nonceattribute,@cspNonce, or a Blade echo mentioning a nonce. The word anywhere else (asrcpath,data-nonce-*, anx-dataexpression) does not count. - Another CSP package next to this one (spatie/laravel-csp, a hand-written middleware): run only one nonce
producer. The header follows whatever nonce
Vite::useCspNonce()holds when the response leaves this middleware, so a producer inside it is fine; one outside it gets its Vite nonce overwritten, and its own header no longer matches the tags. An existing header of the same name is never replaced. - ColorPicker and CodeEditor inject a nonce-less
<style>at runtime (CodeMirror, Pickr). They look broken under the strict preset until you call->allowInlineStyles()(or whitelist the style hashes from your reports). The rich editor works: its Tiptap CSS is printed server-side (->tiptapStyle(false)disables it). - Websockets (Echo/Reverb), CDNs, maps, Google Fonts need entries in
connect-src/img-src/font-src/style-src; run->reportOnly()first and read the reports. - Nonces are per request. Do not cache full HTML responses (CDN page cache,
Cache::rememberof rendered views) together with the header. - Do not re-add
'unsafe-inline'toscript-srcnext to a nonce: browsers then ignore'unsafe-inline', but it signals the policy was copied from somewhere that needed it. - After a Filament upgrade,
TiptapStyleTestfails if Tiptap's bundled CSS changed: copy it intoresources/css/tiptap-core.css.
Uninstalling
Compiled views reference \Asignua\FilamentCspNonce\Nonce. Run php artisan view:clear right after removing the
package, or every panel page fails with "class not found".
Translations
The package has no UI strings, so no language files.
AI agents
Laravel Boost guidelines ship in resources/boost/guidelines/core.blade.php.
Testing
composer install vendor/bin/phpunit vendor/bin/phpstan analyse vendor/bin/pint --test
browser/audit.mjs is a manual, real-browser audit (puppeteer-core + Edge/Chrome) against the workbench; it is how
the claims above were measured.
Changelog
See CHANGELOG.
License
MIT. See LICENSE.

