← All reports

Fix CSP policy loss in blob: URL inheritance when page sends multiple CSP headers

MediumWebCore CSPCrossOrigin

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

f8ed382 | Bugzilla 308906

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

void ContentSecurityPolicyResponseHeaders::addPolicyHeadersTo(ResourceResponse& response) const
{
for (const auto& header : m_headers) {
switch (header.second) {
case ContentSecurityPolicyHeaderType::Enforce:
- response.setHTTPHeaderField(HTTPHeaderName::ContentSecurityPolicy, header.first);
+ response.addHTTPHeaderField(HTTPHeaderName::ContentSecurityPolicy, header.first);
break;
case ContentSecurityPolicyHeaderType::Report:
- response.setHTTPHeaderField(HTTPHeaderName::ContentSecurityPolicyReportOnly, header.first);
+ response.addHTTPHeaderField(HTTPHeaderName::ContentSecurityPolicyReportOnly, header.first);
break;
}
}
}

LayoutTests/http/tests/security/contentSecurityPolicy/resources/echo-multiple-csp-blob-iframe.py

+sys.stdout.write(
+ 'Content-Type: text/html; charset=UTF-8\r\n'
+ "Content-Security-Policy: script-src 'self'; frame-src blob:; default-src 'self'\r\n"
+ "Content-Security-Policy: script-src 'self' 'unsafe-inline'; frame-src blob:; default-src 'self'\r\n"
+ '\r\n'
+ '<iframe id="blob-frame"></iframe>\n'
+ '<script src="/security/contentSecurityPolicy/resources/create-blob-iframe.js"></script>\n'
+)

LayoutTests/http/tests/security/contentSecurityPolicy/resources/create-blob-iframe.js

+var html = [
+ "<p id='result'>PASS: Inline script was blocked by CSP.</p>",
+ "<script>",
+ "document.getElementById('result').textContent = 'FAIL: Inline script executed (CSP policy was dropped).';",
+ "</" + "script>",
+].join("\n");
+var blob = new Blob([html], { type: "text/html" });
+iframe.src = URL.createObjectURL(blob);

LayoutTests/imported/w3c/web-platform-tests/trusted-types/inheriting-csp-for-local-schemes-expected.txt

-FAIL trusted-types directive should be inherited in local blob frames assert_not_equals: got disallowed value null
+PASS trusted-types directive should be inherited in local blob frames

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.

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.

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.

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.