Content-Security-Policy on an unauthenticated login page
The strict policy was report-only and the enforced one allowed unsafe-inline
What makes it hard
Two headers came back on the same response, carrying the same policy with one difference:
content-security-policy:
script-src 'self' 'unsafe-inline' https://... ;
content-security-policy-report-only:
script-src 'nonce-<base64>' 'self' https://... ;The enforced policy substituted 'unsafe-inline' exactly where the report-only one carried the nonce. So the nonce is being generated correctly — the plumbing works — it is simply attached to the header nobody enforces. 'unsafe-inline' in script-src permits any injected inline script or inline handler to execute, which is the whole thing the policy exists to prevent.
This is the shape of a deliberate rollout that was never promoted: ship strict in report-only, collect violations, flip it. The flip did not happen and nothing noticed, because every check anybody had written asked whether a CSP header was present.
The login page is the worst possible place for it. Unauthenticated, handles credentials, and it is the redirect target for every gated route in the product.
Worth recording that the rest of the policy was correct and enforced — frame-ancestors and object-src were fine. A header-level pass/fail would have called this green.
How to cover it
A shared step gets you the positive half:
Then the response header "content-security-policy" should contain "'nonce-"Name the header exactly. content-security-policy-report-only is a different header, and an assertion phrased loosely enough to match either one passes on the observed-and-ignored policy — which is precisely the failure being tested for.
The half that matters has no shared step. There is no negative header assertion in the library, so "the enforcing policy must not contain 'unsafe-inline'" is a project step. Write it; do not settle for the positive assertion alone, because the positive one would have gone green the moment somebody added a nonce alongside 'unsafe-inline', which changes nothing about the security posture.
Two more design notes:
- Headers arrive on every response, so this is an
@apiscenario at@smoke. There is no reason to start a browser to read a header, and a security-headers module that runs in seconds gets run. - Assert per surface, not once. The policy can differ by route, and the unauthenticated pages are the ones that matter most. An outline over the public routes costs one table.
Scenario sketch
@api @smoke @data-driven
Scenario Outline: The enforcing policy is nonce-based on every public page
Given I use no authentication
When I send a GET request to "{{uiOrigin}}<path>"
Then the response status should be 200
And the response header "content-security-policy" should contain "'nonce-"
And the response header "content-security-policy" should contain "frame-ancestors 'self'"
And the enforcing content security policy should not allow "'unsafe-inline'"
Examples:
| path |
| /login |
| /signup |
| / |The last Then is a project step. Everything above it is the shared library; that one line is yours to write, and it is the line that would have caught this.
Other use cases
- Testing an eight-screen auth funnel that has no test idsLogin, signup, email verification, 2FA, the device flow, OAuth consent, invitation acceptance and password reset
- A 403 that a shared cache is free to replayAuthorisation refusals on an API served through a CDN
- Plan caps, spending limits, and a gate that never firedBilling, usage and spending-limit routes behind plan entitlements
- A workflow canvas SDODS cannot dragA node-graph editor: 193 block types, edges, handles, sub-block inputs and subflow containers