Hello,
I am also working on adding CSP to a large SPIP project of mine.
It seems still to be a very custom per-project process to have good CSP. When I check major frameworks and CMSes I find CSP is still avant-guarde and very custom per-project. But the earlier it’s implemented the better, it prevents by itself bad practices of inline scripts etc. So a default configuration would be a very good thing for new projects. But with existing projects, everything has to be checked before applying the header… And it will need to be adapted for the specific project.
To add the CSP, I decided to do it via a SPIP pipeline (to be added at Apache and Nginx level, but I want it to work out of the box).
If you have no inline scripts and styles, you can enforce a very strong CSP. But if you have inline styles and scripts, you can loosen the CSP to accommodate that, and use a nonce for the inline scripts and styles tags.
Over the years, in my project a lot of inline scripts have accumulated… For these we can use a nonce. The only issue there is that ajax forms will not match the original page’s nonce, so the nonce can’t be a true nonce, so for me, until these ajax-inline-scripts are removed, I will keep the nonce per session. It’s less secure, but already a huge win compared to no CSP, the attacked would need to guess the session’s nonce.
Create a nonce: (in mes_fonctions.php)
(for me it’s per session, but if you don’t have inline scripts in ajax forms, you can have a true per request-nonce):
// CSP nonce helper. Stored in session so it remains constant for the user's session
// Todo: use spip's session
function csp_nonce() {
// ensure session is active
if (session_status() === PHP_SESSION_NONE) { @session_start(); }
if (!isset($_SESSION['csp_nonce'])) {
$_SESSION['csp_nonce'] = base64_encode(random_bytes(16));
}
return $_SESSION['csp_nonce'];
}
Then, in all inline scripts, ensure nonce is there (throughout the squelettes):
<script nonce="' . csp_nonce() . '">
And finally, add a CSP header via mon_plugin_pipelines.php (or instead via Apache/Nginx config):
/**
* Add security headers (CSP + related headers) to every public page.
*
* This pipeline sets a conservative Content-Security-Policy and a per-request
* nonce which can be injected into inline <script nonce="..."> tags when
* rendering trusted inline scripts.
*
* @pipeline affichage_entetes_final
* @param array $entetes Associative array of headers
* @return array Modified headers
*/
function mon_plugin_affichage_entetes_final($entetes) {
// Only add CSP for HTML responses. The pipeline receives an associative
// array of headers; check for content-type. If none present, assume HTML
// for public pages and proceed.
// is added to any response that goes through spip (careful, svgs and pdfs are serverd by apache directly. )
$nonce = biz_csp_nonce();
// Compose CSP; prefer a conservative default. This may be made configurable.
$parts = [];
$parts[] = "default-src 'self'";
// Allow our analytics host explicitly so matomo.js and tracking beacons are allowed
$parts[] = "script-src 'self' 'nonce-{$nonce}' https://stats.example.com https:";
// Allow inline styles ('unsafe-inline') unconditionally per your request.
// Note: this weakens CSP but permits style="..." usage site-wide.
$style_src = "'self' 'nonce-{$nonce}' 'unsafe-inline' https:";
$parts[] = "style-src {$style_src}";
// Some browsers enforce separate style-src-attr/style-src-elem directives.
// Explicitly allow inline style attributes and ensure style elements are covered.
$parts[] = "style-src-attr 'unsafe-inline'";
$parts[] = "style-src-elem 'self' 'nonce-{$nonce}' https:";
// Images (allow analytics pixel if needed)
$parts[] = "img-src 'self' data: https://stats.example.com https:";
// Allow fonts from same origin, https and data: URIs (some components inline-fonts via data:)
$parts[] = "font-src 'self' data: https:";
$parts[] = "frame-src 'self' https://www.youtube.com https://player.vimeo.com";
// Allow connect to analytics (beacons, XHRs)
$parts[] = "connect-src 'self' https://stats.example.com https:";
$parts[] = "object-src 'none'";
$parts[] = "base-uri 'self'";
$csp = implode('; ', $parts);
// Allow worker scripts created from blob: URLs (used by some libraries)
$csp .= "; worker-src 'self' blob: https:";
// Add a reporting endpoint so browsers can POST violation reports.
// The endpoint includes the current page as a "context" parameter so the
// receiver can attribute reports to the originating page.
$report_url = url_absolue('/spip.php?page=api_csp&context=' . rawurlencode(self()));
$csp .= '; report-uri ' . $report_url;
// Use enforcement by default; switch to Report-Only via configuration if desired
$entetes['Content-Security-Policy'] = $csp;
// Complementary security headers
$entetes['X-Content-Type-Options'] = 'nosniff';
$entetes['X-Frame-Options'] = 'SAMEORIGIN';
// For Matomo we want to send the full URL as Referer. This sets a permissive
// policy so the browser will include the full URL for cross-origin requests.
// WARNING: this exposes path/query information to third-party hosts.
$entetes['Referrer-Policy'] = 'unsafe-url';
// Enforce HSTS fully when the request is served over HTTPS. Include SubDomains
// and request preload. Detect HTTPS via common server variables to work behind
// reverse proxies (X-Forwarded-Proto).
$is_https = (!empty($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) !== 'off');
if ($is_https) {
// 1 year max-age; includeSubDomains and preload for full enforcement
$entetes['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains; preload';
}
return $entetes;
}
It’s a work in progress for me, but I thought I’d share my approach.
In future never having any inline scripts and styles is the solution, and then there is no nonce, and the CSP header can be very strong…
Urs