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'