Fix CSP policy loss in blob: URL inheritance when page sends multiple CSP headers
CVE: CVE-2026-43660 · Safari 26.5 · Released May 13, 2026 Impact: Processing maliciously crafted web content may prevent Content Security Policy from being enforced Apple's description: A validation issue was addressed with improved logic. Credit: Cantina
Medium — a one-word setter swap, but the word was the difference between "enforce every policy the creator had" and "enforce whichever one the server happened to send last." No memory corruption; the payoff is that CSP stops containing an XSS you already have, and only on sites whose strictest policy isn't last.
Content Security Policy is deliberately conjunctive: a document served two policy headers must satisfy both, so the effective restriction is always the tighter of the two. Documents that never touch the network — blob:, data:, srcdoc, about:blank — have no headers of their own, so WebKit snapshots the creator's policies and replays them onto a synthesized response the new document parses as if it had arrived over the wire. That replay is where cardinality has to survive, and ContentSecurityPolicyResponseHeaders::addPolicyHeadersTo() was writing an N-element policy list into a header map one assignment at a time.
The angle: On a site that layers a strict baseline policy under a looser one, script running in a same-origin blob document is governed only by the looser policy — inline script the parent page blocks executes in the child.
Source/WebCore/page/csp/ContentSecurityPolicyResponseHeaders.cpp
LayoutTests/http/tests/security/contentSecurityPolicy/resources/echo-multiple-csp-blob-iframe.py
LayoutTests/http/tests/security/contentSecurityPolicy/resources/create-blob-iframe.js
LayoutTests/imported/w3c/web-platform-tests/trusted-types/inheriting-csp-for-local-schemes-expected.txt
Patch Details
The functional change is two identifiers. addPolicyHeadersTo() walks m_headers — a vector of {policy string, ContentSecurityPolicyHeaderType} pairs captured from the creator document's policy container — and writes each entry onto the synthesized ResourceResponse. Both switch arms moved from ResourceResponse::setHTTPHeaderField() to ResourceResponse::addHTTPHeaderField(): HTTPHeaderName::ContentSecurityPolicy for Enforce entries, HTTPHeaderName::ContentSecurityPolicyReportOnly for Report entries. setHTTPHeaderField assigns, replacing whatever value that header name already held; addHTTPHeaderField comma-concatenates onto the existing value, matching the HTTP rule that repeated headers are equivalent to a single comma-joined header. The CSP parser splits a comma-separated value back into independent policies, so the round trip now preserves the list.
addPolicyHeadersTo(), m_headers = [P1, P2, P3]
Before (set): After (add):
CSP := P1 CSP := P1
CSP := P2 ← P1 gone CSP := "P1, P2"
CSP := P3 ← P2 gone CSP := "P1, P2, P3"
─────────────────── ───────────────────
child parses {P3} child parses {P1, P2, P3}
The rest of the commit is test scaffolding. echo-multiple-csp-blob-iframe.py is a CGI that emits two distinct enforced Content-Security-Policy headers, ordered so the first forbids inline script (script-src 'self') and the second permits it (script-src 'self' 'unsafe-inline'); both allow frame-src blob:. create-blob-iframe.js mints a blob: document whose body contains an inline <script> that rewrites a PASS string to FAIL if it executes. The expected output records a console refusal in the parent frame — proof both policies bind on the creator — alongside PASS: Inline script was blocked by CSP in the blob child. The WPT expectation for trusted-types/inheriting-csp-for-local-schemes flips FAIL to PASS for "trusted-types directive should be inherited in local blob frames," which is the same truncation observed through a different directive.
Background
Content Security Policy. CSP is an HTTP-header-delivered policy restricting which resources a document may load and whether inline script or eval may run. A response may carry several Content-Security-Policy headers, and a single header value may itself carry several comma-separated policies. Every policy in the resulting list is enforced independently: a load must satisfy all of them, so the effective restriction is their intersection.
Report-only policies. Content-Security-Policy-Report-Only declares policies that are evaluated and reported but never block. They are tracked separately from enforced policies, which is why ContentSecurityPolicyHeaderType distinguishes Enforce from Report and why the switch in the diff has two arms writing two different header names.
Policy container and local-scheme inheritance. A document created without a network fetch of its own — blob:, data:, about:blank, srcdoc — inherits its security context, CSP included, from the document that created it. WebKit models this by snapshotting the creator's CSP headers into a ContentSecurityPolicyResponseHeaders value object and replaying them onto a synthesized ResourceResponse for the new document, which then parses that response exactly as it would a real one. The inheriting document and its creator are same-origin by construction.
ContentSecurityPolicyResponseHeaders. The value object holds m_headers (the vector of policy-string/type pairs) plus the originating HTTP status code. It exposes isolatedCopy() for cross-thread transfer to workers and addPolicyHeadersTo() for replay onto a response.
ResourceResponse header API. setHTTPHeaderField(name, value) assigns, discarding any prior value for that name. addHTTPHeaderField(name, value) appends with comma concatenation. The two exist precisely because HTTP header fields are multi-valued and some are not.
Blob URLs. URL.createObjectURL(new Blob([html], { type: "text/html" })) mints a same-origin blob: URL; navigating an iframe to it creates a document that inherits the creator's policy container. trusted-types and require-trusted-types-for are ordinary CSP directives constraining assignment to DOM injection sinks — like every directive, they live in whichever policy declares them.
Analysis
This is a serialization defect, not a policy-engine defect. ContentSecurityPolicyResponseHeaders models CSP's multi-policy semantics correctly at every internal stage — m_headers is a vector, the constructor builds it, isolatedCopy() preserves it, and the enforcement path iterates all of it. The cardinality loss happens at exactly one seam: the moment that list is flattened back into a header map whose setter is last-write-wins.
Creator document (2 CSP headers) blob: child document
──────────────────────────────── ────────────────────────
P1: script-src 'self' ┐
P2: script-src 'self' ├─► m_headers = [P1, P2]
'unsafe-inline' ┘ │
│ addPolicyHeadersTo()
enforce(P1 ∧ P2) │ set → CSP = P2 only
inline script BLOCKED ✔ ▼
enforce(P2)
inline script RUNS ✘
The left column is the creator: both policies bind, and because enforcement is conjunctive, script-src 'self' from P1 vetoes the inline script that P2 would have allowed. The expected-output file captures that refusal as a console message in the parent frame. The right column is what the loop produced. Each iteration assigned over the previous one, so by the time the synthesized response reached the CSP parser it carried a single value — the last Enforce entry, and separately the last Report entry. The child parsed {P2}, a strictly weaker set, and the inline <script> in the blob document ran.
Note the directionality, because it determines who is affected. Truncation-to-last is not a uniform weakening; it is a weakening that depends entirely on header order. A site emitting a strict baseline followed by a looser per-feature policy loses the baseline inside blob documents. The same site with the headers reversed loses nothing. Two deployments with identical policy sets can therefore differ completely in exposure, which is also why the bug survived: in the overwhelmingly common single-policy case, a loop that assigns once and a loop that appends once are indistinguishable.
The ingestion side of the same class is worth contrasting. The constructor reads httpHeaderField() once per header name and appends one m_headers entry, relying on the network layer having already comma-joined repeated headers into a single value before WebCore sees them:
// ContentSecurityPolicyResponseHeaders::ContentSecurityPolicyResponseHeaders(const ResourceResponse& response)
String policyValue = response.httpHeaderField(HTTPHeaderName::ContentSecurityPolicy);
if (!policyValue.isEmpty())
m_headers.append({ policyValue, ContentSecurityPolicyHeaderType::Enforce });
Ingestion was correct because it leaned on a join that had already happened upstream; egress was wrong because it never performed the join itself. The asymmetry is the whole bug — the code assumed a header map slot is a single string on the way out, while the format it was serializing into treats that slot as a list.
The fix restores the invariant that an inheriting context's effective policy equals its creator's. addHTTPHeaderField() comma-joins each successive policy onto the accumulating value, and since the CSP parser splits a comma-separated header value back into independent policies, {P1, P2} survives the round trip intact. The practical exposure it closes is defense-in-depth erosion rather than a new primitive: an attacker needs an existing same-origin script- or DOM-injection foothold plus a path to create a blob document, and the win is that the injected script executes under materially weaker script-src / frame-src / trusted-types restrictions than the origin's real policy set. There is no memory-safety component, no read/write primitive, and no process-boundary crossing — the blob document is same-origin with its creator by construction, so escalation past XSS-containment loss needs a separate bug.
A conjunctive policy list was serialized through a last-write-wins header setter, so blob documents inherited only the final CSP policy — and whether that weakened the site depended purely on header order.
Insight
If a security policy is conjunctive and multi-valued, every round trip through a single-valued representation is a chance to silently drop its most restrictive member — audit the append/assign choice at each seam, not the policy engine.
Audit directions
-
Replace-semantics setters in serialization loops. The invariant is that serializing a policy list must preserve cardinality, so any writer callable in a loop must append rather than assign. Narrow: grep WebCore for
setHTTPHeaderField(insidefor/range-for bodies, and specifically audit the other synthesized-response builders that replay inherited security state onto aResourceResponse— policy-container replay fordata:,srcdoc, andabout:blankdocuments, plus the workerisolatedCopy()path inContentSecurityPolicyResponseHeaders. Wider: the same shape appears wherever a list-typed security attribute is stored in a map keyed by name — sandbox flag serialization,Permissions-Policy/Feature-Policyassembly,Clear-Site-Data, COEP/COOP reporting endpoints; the tell in search results is a loop body whose only statement writes to a keyed container with assignment semantics. Widest: this is the general "multi-valued header collapsed by a scalar setter" class, live in any HTTP stack exposing bothsetandaddon a header collection — Chromium'snet::HttpResponseHeaders, Go'shttp.Header.Setvs.Add, Node'sres.setHeaderon repeatedSet-Cookie, Rust'sHeaderMap::insertvsappend. The carry-across tell is a loop over a policy or credential list whose body calls the insert/replace variant instead of the append variant. -
The mirror-image defect on ingestion. The constructor reads
httpHeaderField(HTTPHeaderName::ContentSecurityPolicy)exactly once and appends a singlem_headersentry, relying on the network layer having already comma-joined repeated headers. Check every producer ofResourceResponseobjects that WebCore parses CSP from — service-worker synthesized responses,FetchResponse, intercepted-response paths, and the platform response adapters on curl/soup/CFNetwork — and confirm each joins duplicate CSP headers rather than keeping the first or last. Match tell: any response-construction path that iterates raw header lines and calls a set-style API, or any platform header adapter storing headers in a plainHashMap<String, String>with no join step. The adapter can be checked statically; confirming end-to-end behavior per-port needs a two-header test in the shape ofecho-multiple-csp-blob-iframe.py. -
Security state that degrades on inheritance. The invariant is that an inheriting context's effective policy must be equal to or stricter than its creator's, never weaker — and tests that only exercise the parent will pass while the derived context sits unprotected. Narrow: extend the new
blob-url-inherits-multiple-csp-policies.htmlshape to the other local schemes —data:,srcdoc,about:blank,javascript:navigations — and to report-only policies, asserting that a directive carried by a non-final policy still applies in the child. Wider: apply the same differential-inheritance testing to the rest of the policy container — sandbox flags, referrer policy, COEP/COOP, opener relationships — and to Workers and Worklets spawned from a multi-policy document, whereisolatedCopy()adds another hop that must preserve cardinality. Widest: the reusable principle is "test the derived context, not just the origin context" for any inherited-capability system — iframe sandbox in other engines, container-image capability inheritance, OS process-token inheritance. Match tell: a policy assembled once at the origin and copied into a child through a different code path than the one the origin uses; the two paths are where cardinality and ordering get lost. -
Order-dependence elsewhere in CSP handling. Exploitability here hinged entirely on which policy landed last, so trace the paths that build
m_headersand the parser's list construction to confirm enforcement is genuinely order-independent at every stage. Then check report-only interleaving: a report-only policy declared between two enforced policies must not affect enforcement of either. Match tell: any code that indexes into a policy list positionally —m_headers.last(),[0],first()— rather than iterating it. Positional access to a conjunctive set is almost always a truncation bug wearing a different hat.