
A Content Security Policy (CSP) tells the browser which sources a page may load scripts, styles, images and frames from. If an attacker manages to inject a <script> into your page, a good policy stops the browser from running it. It is one of the most useful security headers, and also one of the easiest to get wrong.
The classic mistake I see in TYPO3 projects: somebody copies a policy into the root .htaccess with Header set Content-Security-Policy …. The frontend looks fine. Then an editor calls, because the backend shows a white page or buttons do nothing. In this post I explain why that happens and show the core API that TYPO3 has for this since v12.3.
Why the .htaccess header breaks the backend
The TYPO3 backend already sends its own policy. Since v13 that is not even optional: the feature flag security.backend.enforceContentSecurityPolicy was removed in 13.0, and the backend always protects itself. On my v14 site a request to /typo3/ answers with a header starting like this:
content-security-policy: default-src 'self'; script-src 'self' 'nonce-…' 'report-sample'; style-src 'self' 'unsafe-inline' 'report-sample'; …The nonce is a random value per request, and the backend puts the same value on its own inline scripts. When Apache adds a second Content-Security-Policy header from .htaccess, the browser does not choose one. It enforces both, and a resource must pass every policy. Your hand-written policy does not know the nonce, so the backend’s scripts are blocked. White page.
The fix is not a smarter regex in .htaccess. It is to let TYPO3 build the policy, because TYPO3 knows the nonce, knows the site, and knows which extensions need which sources.
Step 1: switch on the frontend policy
For the frontend, two feature flags exist, both off by default:
security.frontend.enforceContentSecurityPolicysends aContent-Security-Policyheader.security.frontend.reportContentSecurityPolicysendsContent-Security-Policy-Report-Only: violations are reported, nothing is blocked.
Always start with report-only. In v13 you set it in config/system/settings.php or additional.php:
$GLOBALS['TYPO3_CONF_VARS']['SYS']['features']['security.frontend.reportContentSecurityPolicy'] = true;Since v14.2 there is also a console command for it (Feature-108815):
ddev exec vendor/bin/typo3 configuration:set SYS/features/security.frontend.reportContentSecurityPolicy true --jsonThe core already provides a sensible default frontend policy in EXT:frontend’s Configuration/ContentSecurityPolicies.php: default-src 'self', scripts with a nonce, data: images, and a few YouTube and Vimeo hosts for media embeds.
Step 2: per-site rules in csp.yaml
Each site can have a csp.yaml next to its config.yaml. Since 12.4.x it can set the disposition per site, so you do not need the global flags at all if you prefer this. Here a site that enforces the default policy, plus one external host for embedded maps, and at the same time tests a stricter img-src in report-only mode:
# config/sites/main/csp.yaml
enforce:
inheritDefault: true
includeResolutions: true
mutations:
- mode: extend
directive: 'frame-src'
sources:
- 'https://maps.example.org'
report:
inheritDefault: true
mutations:
- mode: set
directive: 'img-src'
sources:
- "'self'"extend takes the parent directive (here default-src 'self') and adds sources. set replaces the directive completely. And active: false switches CSP off for one site, which is handy when one site of a multi-site installation is not ready yet.
Step 3: rules that belong to an extension
If your extension embeds a third-party widget, the extension should bring its own rule. Then every site that uses the extension gets it, and you do not repeat it in every csp.yaml. The file is Configuration/ContentSecurityPolicies.php in the extension:
<?php
declare(strict_types=1);
use TYPO3CMSCoreSecurityContentSecurityPolicyDirective;
use TYPO3CMSCoreSecurityContentSecurityPolicyMutation;
use TYPO3CMSCoreSecurityContentSecurityPolicyMutationCollection;
use TYPO3CMSCoreSecurityContentSecurityPolicyMutationMode;
use TYPO3CMSCoreSecurityContentSecurityPolicyScope;
use TYPO3CMSCoreSecurityContentSecurityPolicyUriValue;
use TYPO3CMSCoreTypeMap;
return Map::fromEntries([
Scope::frontend(),
new MutationCollection(
new Mutation(MutationMode::Extend, Directive::FrameSrc, new UriValue('https://maps.example.org')),
new Mutation(MutationMode::Extend, Directive::ImgSrc, new UriValue('https://tiles.maps.example.org')),
),
]);A site can drop the rules of one package again with the packages key in csp.yaml, if it does not want them.
Step 4: inline scripts and nonces in Fluid
The default policy allows scripts from your own host and scripts with the current nonce. Inline scripts without the nonce are blocked. The best fix is to move the script into a file. Where that is not possible, the asset ViewHelpers handle it for you. In v14.2 and later:
<f:asset.script identifier="consent-init" csp="1">
document.documentElement.classList.add('js');
</f:asset.script>In v13 the same argument is called useNonce="1". It still works in v14, but since 14.2 it writes a deprecation and will be removed in v15 (Deprecation-100887). If you write the tag yourself, v14 has {f:security.nonce(directive: 'script-src')} for the attribute value.
One thing to know: a nonce makes the response uncacheable for proxies, because the value changes per request. TYPO3 already avoids nonces when a page does not need them (an important change in 13.4.x). And v14.2 added hash-based CSP as an opt-in per site, with behavior.useHash: true and useNonce: false in csp.yaml, so fully cached pages can keep a strict policy (Feature-100887).
Step 5: read the violation reports
With the core reporting endpoint, violations are stored in the table sys_http_report and shown in the backend module “Content Security Policy”. In v13 you find it under Admin Tools, in v14 under System. The module groups the reports and can even suggest a resolution, which is stored and applied at runtime (that is what includeResolutions above refers to). I prefer to copy a good resolution into csp.yaml or the extension file, because then it is in git and reviewed.
My routine: report-only for a week or two, clear out the expected noise (browser extensions inject a lot), fix the real ones, then switch the site to enforce.
For my own site hoja.in, the frontend flag is still off, I have to admit. The site loads no third-party resources anymore after the rebuild, so it is a good candidate for the default policy. Report-only comes first, as described above. Practise what you preach, Hoja.
Cheers, and keep the backend working!