Content-Security-Policy for Magento 2: Report-Only vs Restrict, and Tuning to Stop Skimmers
Magekwik Security 5 min read
Magento_Csp ships most storefront pages in report-only mode, which enforces nothing. This is how report-only and restrict actually differ, why an over-broad whitelist still lets skimmers run, and the exact sequence to tune csp_whitelist.xml and flip to restrict without breaking checkout.
What it is
The Magento_Csp module (shipped since 2.3.5) assembles and emits the Content-Security-Policy and Content-Security-Policy-Report-Only response headers. It runs in one of two modes, set independently for the storefront and admin areas. In report-only mode the browser evaluates the policy, logs every violation to the configured report-uri/report-to endpoint, and enforces nothing. In restrict mode the browser refuses to load or execute any resource the policy does not permit.
The effective policy is built at runtime by merging every enabled module's etc/csp_whitelist.xml with the per-area mode flags in etc/config.xml. Since 2.4.7, restrict mode is the default only for payment pages (storefront and admin); every other page defaults to report-only. Before 2.4.7, the entire application was report-only. That default is the crux of the problem.
Root cause
There are two independent failure modes, and most exposed stores have both.
First, report-only is telemetry, not a control. A Content-Security-Policy-Report-Only header blocks nothing. A large share of stores run report-only across the whole storefront, never process the reports, and believe the presence of a CSP header means they are protected. It does not.
Second, even in restrict mode the shipped policy is intentionally permissive so third-party integrations do not break on upgrade. Directives fall back to broad host lists, and inline execution is frequently allowed through 'unsafe-inline' or an over-wide script-src. A policy that permits inline script or wildcards a payment/analytics origin is satisfied by an injected inline handler or a skimmer served from an already-whitelisted host. CSP is only as strong as the smallest source set you can get the store to run on.
How attackers abuse it
A web skimmer needs two capabilities the browser normally grants freely: execute attacker JavaScript in the checkout document, and exfiltrate the captured card data to an attacker-controlled host. A correctly restricted CSP removes both.
script-srcwithout'unsafe-inline'(nonce- or hash-based) blocks inline event handlers and injected inline<script>blocks outright.connect-src/default-srcandform-actionconstrained to your own origins block the exfiltration beacon (fetch,XMLHttpRequest,navigator.sendBeacon, or a rogue form POST).
Report-only defeats all of this silently: the browser dutifully files a violation report while the skimmer runs and the card data leaves. An over-broad whitelist defeats it too, because the injected code either runs inline or reuses a domain you already trust.
Report-only is not mitigation
Who got hit / real examples
In April 2026 Sansec documented a campaign that hid a skimmer inside a 1x1 SVG element on 99 Magento storefronts. The entire payload was base64-encoded inside the SVG's onload handler, decoded with atob() and detonated via setTimeout. It intercepted checkout clicks with useCapture, presented a fake "Secure Checkout" overlay, harvested card and billing fields, then exfiltrated to one of six domains (all resolving to 23.137.249.67) via a /fb_metrics.php endpoint masquerading as Facebook analytics. An inline onload handler and an off-origin beacon are exactly what a nonce-based script-src and a tight connect-src refuse.
The upstream supply of such compromises is well understood. CosmicSting (CVE-2024-34102, CVSS 9.8, NVD-published 2024-06-13, added to CISA KEV 2024-07-17) let unauthenticated attackers read app/etc/env.php, recover the encryption key, and then rewrite CMS blocks through the Magento API to plant JavaScript. Sansec tracked waves compromising 2,000-plus and later 3,000-plus stores. CSP does not stop the initial file read, but a restricted policy caps the blast radius of the injected script that follows.
How to check
Inspect the live headers on a checkout URL and confirm which header name you actually get back.
curl -sSI https://store.example.com/checkout/ | grep -i 'content-security-policy'
# content-security-policy-report-only: ... -> ENFORCES NOTHING
# content-security-policy: ... -> enforced (restrict)
Then confirm the configured mode per area and review the whitelist surface you are trusting.
php bin/magento config:show csp/mode/storefront/report_only # 1 = report-only, 0 = restrict
php bin/magento config:show csp/mode/admin/report_only
# Every host you are trusting, across all modules:
grep -rho 'type="host"[^>]*>[^<]*' app/ vendor/ --include=csp_whitelist.xml | sort -u
In restrict mode, the admin's CSP violation report grid (and your report-uri collector) surfaces legitimate resources that still need whitelisting before you tighten further.
How to fix it
The safe sequence is deliberately report-only first, then flip to restrict once the policy is clean. Do not skip the collection phase or you will break checkout.
- Stay in report-only, drive real checkout/admin traffic, and collect violations from your
report-uriendpoint until the report stream is quiet. - Add only the hosts you genuinely require to a module
etc/csp_whitelist.xml, scoped to the narrowest directive. Remove leftover analytics/tag-manager origins you no longer use.
<csp_whitelist xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Csp:etc/csp_whitelist.xsd">
<policies>
<policy id="script-src">
<values>
<value id="psp" type="host">https://js.your-psp.com</value>
</values>
</policy>
<policy id="connect-src">
<values>
<value id="psp-api" type="host">https://api.your-psp.com</value>
</values>
</policy>
</policies>
</csp_whitelist>
$secureRenderer so it is nonce-covered rather than blanket-allowed with 'unsafe-inline'. Prefer nonces/hashes over host wildcards.<default>
<csp>
<mode>
<storefront><report_only>0</report_only></storefront>
<admin><report_only>0</report_only></admin>
</mode>
</csp>
</default>
After the flip, keep the report-uri collector running. In restrict mode those reports are now your intrusion signal: an unexpected connect-src or inline script-src violation on checkout is a skimmer being blocked in real time, not a tuning task.
Order of operations
report_only to 0 before the report-only stream is quiet. The correct failure mode during tuning is a logged violation, never a broken checkout for a paying customer.References & sources
Primary sources — advisories, vendor research, and the CVE record. Verify against these, not us.