Applies to security.csp-enforced-baseline.

Covers security.csp-enforced-baseline. Related: security.response-headers, html.no-inline-handlers, security.rest-route-authorisation, perf.third-party.

A Content-Security-Policy sent as Content-Security-Policy-Report-Only blocks nothing at all, and report-only is where most sites stop for good, which is why this rule asks for a second, enforcing header carrying four directives that cost a normal build nothing, because not one of them looks at a script or a style.

What the spec gives you, and what this rule adds

The Content-Security-Policy-Report-Only HTTP response header field allows web developers to experiment with policies by monitoring (but not enforcing) their effects.

That sentence is all CSP3 gives, and it is the whole basis of the rule. The spec mandates no baseline. §6.1.9, §6.3.1, §6.4.1 and §6.4.2 define object-src, base-uri, form-action and frame-ancestors without requiring any. The four-directive list is OWASP's Secure Headers proposal, the same source security.response-headers cites, so like 99 of this standard's 100 externally cited rules, this one is partial and the measurable is ours. CSP3 is also still a Working Draft rather than a Recommendation, so do not call it one to a client.

Both reference builds stopped at report-only

Measured on the live responses on 2026-08-25: each sends exactly one CSP header and it is content-security-policy-report-only, the enforcing name appearing in either theme only in a comment (inc/csp-report.php:58 and :68). They did not forget it. Both report-only policies already carry all four directives - object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self' - so the enforcing header was one header() call away in each and neither made it. One build's staging host does send an enforcing header, but it is the provider's, it says upgrade-insecure-requests, and it carries none of the four. A CSP on the response and a policy on the site are different claims.

Why the baseline is exactly these four

Directive What it stops What it costs
object-src 'none' <object>, <embed>, legacy plugin content Nothing, unless something on the site embeds a PDF
base-uri 'self' An injected <base> silently repointing every relative URL on the page Nothing
form-action 'self' The injected form post that sends the contact form elsewhere, which is this rule's why Nothing, unless a form really does post to a third party
frame-ancestors Your page framed on someone else's site Nothing. It also covers what X-Frame-Options: SAMEORIGIN does in security.response-headers

What breaks when you add a fifth

Inline script. script-src 'self' kills the JSON-LD block, the dataLayer bootstrap and the tag container snippet in one go, and style-src stops GSAP dead, since GSAP writes an inline style on every element it touches. The usual escape, 'unsafe-inline', is the one setting that lets an injected script run, which is the argument html.no-inline-handlers is built on. Nonces and hashes are the real answer and they are work: every inline block has to come from the code path that writes the header.

The tag manager. Its container is not in your repository, so an allowlist describes what the page loaded the day you wrote it. On one reference build the origin inventory at inc/csp-report.php:47-64 did not contain connect.facebook.net while the live page loaded fbevents.js (110.3 KB), a Meta pixel and an AWS bundle, none of which appear anywhere in that repo. 275.85 ms of its 396.2 ms of third-party main-thread time was that unlisted origin. Enforce script-src off an inventory like that and the next tag someone adds goes dark silently.

An embedded map or video. frame-ancestors governs who may frame you; an embed needs frame-src, or falls through to default-src. Assuming the map is handled because frame-ancestors is set costs an afternoon, and it bites the other way too: the first default-src you add blanks the embed, with no evidence but a console message nobody was watching for.

Shipping both headers

// Both names on one response, off a send_headers hook that returns early in wp-admin.
header( "Content-Security-Policy: object-src 'none'; base-uri 'self'; "
      . "form-action 'self'; frame-ancestors 'self'" );                   // enforced, short
header( 'Content-Security-Policy-Report-Only: ' . build_full_policy() );  // wide, harmless

Two policies are not merged into one; each applies on its own terms. The enforcing header carries only what you are sure of, and the allowlist stays where it cannot break anything, derived from a function that knows which vendors are enabled rather than typed into a string. In the console, check which header a violation is attributed to: the accept asks for zero from the enforcing one.

Report-only also creates a public write endpoint, because the collector takes a POST from every browser and cannot require a login. Both builds bound it as security.rest-route-authorisation asks: deduplicate, then cap at 200 stored keys with autoload off (inc/csp-report.php:130-159 and :149-183).

Read the live response, never the file

The enforcing policy and the baseline headers ship from different places, and only one of them travels with the theme. The policy is emitted from the theme itself, inc/csp-report.php, so a theme rsync carries it. The baseline headers next to it come from a mu-plugin, and mu-plugins/ is excluded from that rsync on purpose because the deploy script syncs it as its own step (.deployignore:48-50 in the starter). Skip that one step and security.response-headers fails silently while the repository still says every header is there.

A page cache serves a static file and PHP never runs. A send_headers hook cannot fire on a response the cache serves itself; both builds also declare the headers as Header always set in the root .htaccess, which is what covers that. Between the two, this rule is verified assisted.

# Two CSP lines means a host, a CDN or a security plugin sends one too, and theirs is enforced.
curl -sI https://example.com/ | grep -iE 'content-security-policy|x-frame|strict-transport'