skip to content

A results page's origin sends its own Content-Security-Policy and a content delivery edge appends another; how does a conforming browser combine them, and why does the origin's nonce not satisfy both?

level: seniorimportance: should knowfreq 45%

answer

  1. One field or two, same result
  2. A list-based response header field
  3. Semicolons inside, commas between
  4. Repeated directive name is ignored
  5. Nonce belongs to one policy only

basics

~20 s

Policies on one response are never combined. Two header fields and one comma-joined field parse into the same list, and each policy is enforced whole. A nonce-source belongs to the policy declaring it, so the other policy is unaffected by it.

solid answer

~40 s

`Content-Security-Policy` is a list-based response header field, so two separate field lines and one comma-joined field line produce exactly the same thing: several **serialized policies** in one list. The browser parses each member into its own policy, gives the `Content-Security-Policy` members disposition `enforce` and the `Content-Security-Policy-Report-Only` members disposition `report`, and then consults every policy for every fetch and every inline execution. There is no merge, no precedence and no last-one-wins. That is why the origin's per-response nonce buys nothing downstream: a `nonce-source` is a source expression inside one policy's source list, so the edge's policy — which lists hosts and no `nonce-source` — still has no rule that allows an inline script, and blocks it.

code

http · 4 lines
http
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Security-Policy: script-src 'nonce-r4nd0mPerResponse'; object-src 'none'
Content-Security-Policy: default-src 'self' https://static.results.example

go deeper

for a junior

Remember the one-line version: a response can carry more than one policy, and the browser keeps them separate. If a script is blocked, check how many policies arrived before blaming the directive you wrote.

for a middle

Be able to explain the grammar: commas separate policies, semicolons separate directives inside a policy, and a directive name repeated inside one policy is ignored rather than merged. Then show that repeated header fields and one comma-joined field are identical.

for a senior

Demonstrate the diagnosis. Given an inline script with a valid fresh nonce that still does not run, name the second policy as the cause, explain that a nonce-source is scoped to its own policy, and say how you would confirm it from the raw response.

for a principal

The tradeoff you own is who is allowed to append a policy at all. A per-response nonce and an intermediary-authored policy are structurally incompatible on the same directive, so decide where policy authorship lives before two teams each ship something correct.

## A response carries a list of policies, not a policy A `Content-Security-Policy` response header field does not carry *a* policy. It carries a **serialized-policy-list**: one or more `serialized-policy` members separated by commas. `Content-Security-Policy` is an ordinary list-based field under the list rule of **RFC 9110**, and that rule says a field defined as a list may appear either as a single field line with comma-separated members or as several field lines sharing the name. Both spellings produce the same list of members. The consequence is blunt. An origin that sets its own policy and an intermediary that appends one are not negotiating and are not overriding each other. They are contributing members to one list, and the document ends up under two policies while each team believes there is one. ## How the browser builds that list When a conforming browser processes the response it does, in order: 1. Take the header list values for `Content-Security-Policy`, parse each member into a policy, and give every one the disposition `enforce`. 2. Take the header list values for `Content-Security-Policy-Report-Only`, parse each member the same way, and give every one the disposition `report`. 3. Append all of them, in order, into one CSP list attached to the document. Nothing in that procedure compares one policy with another. Each policy that lands in the list is consulted **whole and on its own terms** for every fetch and every inline execution the document attempts. A policy with disposition `report` blocks nothing — but it is still a separate member of the list, not a modifier of the enforcing one, and its violations are reported under its own disposition. ## Inside one policy the rules are different The merging people expect between policies does happen one level down, inside a single serialized policy: - A serialized policy is split on **semicolons** into directives. - A directive is a name followed by whitespace-separated source expressions — a `serialized-source-list`. - If a directive name appears **twice in the same policy**, the second occurrence is **ignored**. It does not add sources and it does not replace the first. So `script-src 'self'; script-src https://tally.example` inside one policy behaves as `script-src 'self'`. Write the same two directives as two policies and the shape inverts: now there are two policies and a script has to satisfy each. | You wrote | Delivered as | What the browser does | |---|---|---| | `script-src 'self'; script-src https://tally.example` | one policy | keeps the first `script-src`, ignores the second | | `script-src 'self'` and `script-src https://tally.example` | two policies — two field lines, or one comma-joined field line | parses both, consults both, and a script must satisfy each | Those two rows are the whole trap. The same pair of directives means the opposite thing depending on whether a semicolon or a comma sits between them. ## Why the origin's nonce does nothing downstream A `nonce-source` is a source expression written inside one policy's source list, matched against the `nonce` content attribute of the element. It belongs to **that policy only**. Now put the results page together. The origin renders the tally markup, mints a fresh unguessable value for this response, and writes it into both its own policy and the `<script>` element. A content delivery edge, configured by a different team, appends a conservative policy that lists hosts and `'self'` and knows nothing about nonces. The inline tally script then: - **satisfies** the origin's policy, because the element's nonce matches the `nonce-source` declared there; - **fails** the edge's policy, because that policy's `script-src` contains no `nonce-source` and no `'unsafe-inline'`, so it has no rule that permits an inline script element to execute; - is therefore **not executed**, and the violation is reported against the edge's policy. The page team sees a nonce that is fresh, correct and correctly attached, and a script that still does not run. The mechanism is not a bad nonce. It is that a nonce is scoped to the policy that declares it, and the document must satisfy every policy on the response independently. An intermediary cannot be handed the nonce either, because the value changes per response and the intermediary does not render the body. ## What this means operationally - **Count the policies before you debug the directives.** Commas separate policies; semicolons separate directives within one. - Two teams can each ship a policy that is correct in isolation and together produce a document that executes nothing. - A downstream policy and a nonce-based origin policy only coexist if the downstream policy allows the origin's inline code by its own means, or does not constrain that directive at all. - Reading the response as a single concatenated allowlist is the error that makes all of this look like a browser bug.

  • A Content-Security-Policy-Report-Only policy arrives on the same response as an enforcing one. Are the two merged?
    No. Each is parsed into a separate member of the response's policy list — the enforcing one with disposition `enforce`, the other with disposition `report`. The report-disposition policy blocks nothing, but it is not a modifier of the enforcing policy either. It is evaluated on its own terms, and what it would have blocked is reported under its own disposition.
  • From the response alone, how do you work out how many policies a document is actually under?
    Count the comma-separated members across every `Content-Security-Policy` field line, then do the same for `Content-Security-Policy-Report-Only`. Commas separate policies; semicolons separate directives inside one policy. One field line holding two commas is three policies; two field lines each holding one policy is two policies.
  • Does the order of the policies on the response matter?
    Not for the outcome of a given fetch. Order determines the order the list is built and evaluated in, but there is no precedence between members — a request has to be allowed by each policy, so no member can be shadowed or overridden by an earlier or later one.

Two doormen stand at one door, each holding a guest list drawn up by a different office. Being on one list gets you past that doorman and no further; nobody staples the lists together.

saying these in an interview costs you the question

  • Says the two policies merge into one combined allowlist
  • Thinks a later policy overrides an earlier one on the same response
  • Assumes the origin's nonce also satisfies a policy appended downstream
  • Treats a comma-joined field and repeated field lines as different things
  • Believes a repeated directive name inside one policy adds sources
  • Calls a repeated Content-Security-Policy field line a malformed response