Skip to content

← All use cases

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:

text
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:

gherkin
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 @api scenario 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.
Verified on one environment only. Policies commonly differ per environment, and a security assertion that has only ever run against staging is a claim about staging. Run the module against each environment you care about before you report the finding as fixed.

Scenario sketch

gherkin
@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