akibeo/kirby-csp

Content-Security-Policy headers with per-request nonce for Kirby, following Google's strict CSP guidance.

Maintainers

Package info

github.com/wdebusschere/kirby-csp

Homepage

Issues

Type:kirby-plugin

pkg:composer/akibeo/kirby-csp

Transparency log

Fund package maintenance!

e-xperience.pt

Statistics

Installs: 12

Dependents: 0

Suggesters: 0

Stars: 2

1.0.3 2026-07-27 10:05 UTC

This package is auto-updated.

Last update: 2026-07-27 10:06:21 UTC


README

Tests Kirby 4/5 License MIT

Sends a strict Content-Security-Policy header with a per-request nonce for Kirby, following Google's strict CSP guidance'strict-dynamic' + nonce, with https: / 'unsafe-inline' kept only as a legacy-browser fallback.

  • Opt-in and per-host — disabled by default, enable per environment via config.<host>.php.
  • Report-only rollout — test a policy against real traffic before enforcing.
  • Per-request noncecspNonce() helper for inline scripts, Vite tags, and third-party snippets.
  • Panel-safe — the header is only sent on frontend routes; Panel, API and media are left untouched.

Installation

Composer

composer require akibeo/kirby-csp

Download / Git submodule

Copy this repository into site/plugins/kirby-csp/:

git submodule add https://github.com/akibeo/kirby-csp.git site/plugins/kirby-csp

No build step is required — Kirby autoloads plugins from site/plugins/. The plugin registers itself as akibeo/csp and reads its options from the akibeo.csp namespace.

Porting to an existing site? Follow SETUP.md — a self-contained rollout guide (copy files, find scripts that need nonces, whitelist domains, test in report-only, enforce). It's written so you can also hand it to an AI agent as-is.

Configuration

The plugin is disabled by default. Enable it in site/config/config.php or a host config (config.<host>.php):

return [
    'akibeo.csp' => [
        'enabled' => true,

        // Optional: only send the header on these hosts (compared
        // lowercase, without port). Empty array = all hosts.
        'hosts' => ['www.example.com'],

        // Optional: test the policy without enforcing it — sends
        // Content-Security-Policy-Report-Only instead.
        'reportOnly' => true,

        // Required when Kirby's pages cache is enabled — see
        // "Pages cache" below.
        'cacheSafe' => true,
    ],
];

Overriding directives

Directives are an associative array of directive => value. Config values merge over the defaults per directive, so you only specify what you change. {nonce} is replaced with the per-request nonce:

'akibeo.csp' => [
    'enabled' => true,
    'directives' => [
        'frame-src' => "'self' https://www.youtube.com",
    ],
],

The defaults (see index.php) are a deliberately minimal, vendor-neutral strict baseline — everything is 'self' plus the 'strict-dynamic' + nonce script policy. Add the origins your project actually uses on top.

Example: Google Fonts + Analytics / Tag Manager + Maps

'akibeo.csp' => [
    'enabled' => true,
    'directives' => [
        'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com",
        'font-src' => "'self' data: https://fonts.gstatic.com",
        'img-src' => "'self' data: https:",
        'connect-src' => "'self' https://www.google-analytics.com https://www.googletagmanager.com https://analytics.google.com https://region1.google-analytics.com",
        'frame-src' => "'self' https://www.google.com",
    ],
],

See SETUP.md for a per-vendor directive table (Mapbox GL, Fontshare, GTM, reCAPTCHA, …).

Usage

Nonce for inline scripts

With 'strict-dynamic', inline scripts are blocked unless they carry the request nonce. Use the cspNonce() helper in templates and snippets:

<script nonce="{{ cspNonce() }}">
    // inline script allowed by the CSP
</script>

The nonce is generated once per request and memoized, so every call returns the same value that was sent in the header.

External scripts loaded by a nonced script are allowed automatically via 'strict-dynamic'; static <script src> tags need the nonce attribute too. Consent-gated <script type="text/plain"> tags and application/ld+json data blocks need no nonce — the former are re-injected by the (nonced) cookie-consent script, the latter are never executed.

Inline event handlers must be rewritten

A nonce lives on a <script> tag, so there is nothing to attach one to on onclick="…". The 'unsafe-inline' fallback in the default script-src does not cover them either — browsers ignore it once a nonce is present in the same directive. Every on*= attribute and href="javascript:…" therefore fails with "Executing inline event handler violates the following Content Security Policy directive". Move the behaviour into a bundled script:

<button onclick="toggleTheme()">…</button>          {{-- blocked --}}
<button type="button" data-theme-toggle>…</button>  {{-- ok --}}
document.addEventListener('click', (event) => {
    if (event.target.closest('[data-theme-toggle]')) toggleTheme();
});

'unsafe-hashes' plus a per-handler hash would also work, but it weakens the policy and turns every handler edit into a config change. See SETUP.md for details.

Nonce for Vite tags

The tags printed by vite() are parser-inserted and need the nonce as well. The lukaskleinschmidt/kirby-laravel-vite plugin accepts a callable, resolved once per request:

'lukaskleinschmidt.laravel-vite' => [
    'nonce' => fn () => cspNonce(),
],

Script tags from Composer-managed plugins

Plugins whose folders are gitignored (e.g. a cookie-consent plugin) can't be patched in place. Override their snippet instead: a file with the registered snippet name in site/snippets/ (e.g. site/snippets/cookieconsentJs.php) takes precedence over the plugin's version — copy it and add 'nonce' => cspNonce() to the js() attribute arrays.

Finding scripts that need a nonce

Before enforcing, every inline <script> and static <script src> in the templates needs the nonce attribute. Quick greps:

# Inline and static script tags (excluding already-nonced ones)
grep -rn "<script" site/templates site/snippets | grep -v "cspNonce()"

# Inline event handlers — these can't be nonced, they must be rewritten
grep -rnoEi '(^|[[:space:]])on[a-z]+="[^"]*"|href="javascript:[^"]*"' site/templates site/snippets

# Third-party origins referenced anywhere in the frontend
grep -rhoE 'https://[a-z0-9.-]+' site/templates site/snippets assets | sort -u

Pages cache

Kirby's pages cache (file, Redis, Memcached, … — the driver doesn't matter) stores the rendered HTML, including any nonces baked into it. The CSP header is regenerated with a fresh nonce on every request, so on a cache hit the header and HTML nonces no longer match — and since 'strict-dynamic' makes modern browsers ignore 'self', every script on a cached page would be blocked.

Enable cacheSafe to fix this:

'akibeo.csp' => [
    'enabled' => true,
    'cacheSafe' => true,
],

With cacheSafe the HTML is cached with a stable placeholder instead of a real nonce (via page.render:after, whose output is what the pages cache stores), and the placeholder is swapped for the current request's nonce on every response — cache hits included — through an output buffer. Templates keep calling cspNonce() as usual; nothing else changes.

It's off by default because sites without a pages cache don't need the extra output buffering. Requires Kirby 4+ (the page.render:after hook).

Security note: the placeholder never appears in responses (only inside the cache), but the default value is public knowledge — it's a constant in this open-source repo. If untrusted, user-supplied HTML can end up in cached pages, an attacker could inject <script nonce="…placeholder…"> and receive a valid nonce after the swap, bypassing the nonce protection. On such sites, set a random per-site secret:

'akibeo.csp' => [
    'cacheSafe' => true,
    // e.g. generate once with: php -r "echo bin2hex(random_bytes(16));"
    'cacheSafePlaceholder' => 'nonce-ph-3f9c2a1d8e4b6f70a5c3d2e1b4a69808',
],

The value must stay stable while cached pages exist — changing it requires flushing the pages cache. And as always, CSP is defense in depth: sanitize untrusted HTML regardless.

Testing a policy

  1. Set 'reportOnly' => true and 'enabled' => true.
  2. Browse the site with DevTools open — violations show up in the console but nothing is blocked.
  3. Once the console stays clean, switch reportOnly off to enforce.

See SETUP.md for the full rollout checklist and a vendor-specific directive table (Mapbox GL, Fontshare, GTM, reCAPTCHA, …).

Development

composer install
composer test      # vendor/bin/phpunit

The header-building logic lives in Akibeo\Csp\Csp (src/Csp.php) — pure, Kirby-free helpers covered by the test suite in tests/.

License

MIT — © E-xperience LAB

Credits

  • Wannes Debusschere
  • Yassine El Bouazzaoui