Fix CSP bypass in sandboxed srcdoc iframes
CVE: CVE-2026-20665 · Safari 26.4 · Released March 24, 2026 Impact: Processing maliciously crafted web content may prevent Content Security Policy from being enforced Apple's description: This issue was addressed through improved state management. Credit: webb
Medium — no memory corruption, just a policy that silently stops applying. But it removes CSP as an XSS mitigation inside any frame an injected script creates, and the trigger is four lines of ordinary JavaScript with no heap grooming and no race to win.
Every document that ships with response headers can state its own security policy; documents that have no response — about:blank, data:, blob:, about:srcdoc — have to borrow one. The HTML spec is specific about whom they borrow from: the initiator that requested the navigation, or the parent frame in the srcdoc case. WebKit bundles those borrowed policies into a policy container (CSP, referrer policy, COOP/COEP, sandbox flags) and hands it to the new document at commit time, and there is one more place a policy container can come from — a session history entry, saved so that back/forward traversal restores exactly what a document had when it first loaded. The invariant at stake is that the history copy is authoritative only when history is actually being replayed.
The angle: A script with an injection foothold on a CSP-protected page can create an iframe, assign srcdoc to it, and get a document that enforces no policy at all — free to pull in remote script the page's script-src forbids.
Source/WebCore/loader/DocumentWriter.cpp
LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
Patch Details
The production change is one line. Inside DocumentWriter::begin(), the branch that copies a policy container out of the frame's current session-history entry gains two additional conjuncts: triggeringAction && triggeringAction->type() == NavigationType::BackForward. Nothing else moves — the function signature is untouched (triggeringAction was already a parameter, just unconsulted at this site), the body of the branch is identical, the neighbouring hasSubstituteData lambda is unchanged. The history branch is no longer a default path that any navigation can fall into; it is now a path only a traversal can reach.
Everything else in the commit is test collateral, and it is the more informative half of the diff.
The new regression test, iframe-srcdoc-import-bypass.html, is built as an A/B comparison. The page declares script-src 'self' 'unsafe-inline' 'unsafe-eval' and creates two sandbox="allow-scripts" iframes that differ in exactly one respect: iframe1 carries no srcdoc attribute at all, iframe2 carries srcdoc="". At DOMContentLoaded both get the same markup assigned from script — an inline module import() of a cross-origin URL, with the result posted back to the parent. module-pass.py is the target: eight lines of Python that serve an empty application/javascript body with Access-Control-Allow-Origin: *, so CORS never becomes the thing that blocks the load. If the import fails, it fails because CSP refused it. The expectations file asserts iframe1Loaded is false and iframe2Loaded is false, with three matching console refusals — one stylesheet, two scripts — recorded above them.
The rebaseline of the imported WPT file is where the bug's true blast radius shows up. Four subtests in inheritance-from-initiator.sub flip from FAIL to PASS, and the recorded failure text is unusually legible:
expected "a" but got "p" # contentWindow.location, window.open()
expected "b" but got "p" # form submission via submit() and button click
Each test tags documents by role — a and b are initiators, p is the previous document. The baseline in the tree was a written record that WebKit had been handing new documents the previous document's policy instead of the initiator's, across four navigation shapes that have nothing to do with srcdoc.
Background
Policy container. WebCore bundles a document's inheritable security state into a single object: Content Security Policy, referrer policy, cross-origin opener and embedder policy, and sandbox flags. Document::inheritPolicyContainerFrom() installs such a bundle onto a freshly created document.
Content Security Policy. A per-document policy, delivered by response header or <meta http-equiv>, restricting which script, style, and connection endpoints the document may use. script-src 'self' limits script loads — including ES module import() — to the document's own origin.
Local schemes. about:blank, about:srcdoc, data:, and blob: URLs produce documents with no response of their own, and therefore no headers that could carry a policy. The HTML spec has such documents inherit their policy container from the navigation initiator, or from the parent frame in the srcdoc case.
srcdoc iframes. An <iframe srcdoc="..."> renders the attribute's markup as an about:srcdoc document. The attribute may be present at parse time or assigned later from script; either way, assigning it initiates a navigation of the frame.
Initial empty document. A newly created frame synchronously commits an about:blank document before any real load, so a frame element always has a live document — and associated per-frame session-history state — even before its first navigation. This is spec-mandated behaviour, not a WebKit quirk.
HistoryItem and its policy container. HistoryItem is WebCore's session-history entry for a single frame. Alongside URL and scroll state it stores a policyContainer(), so that a back/forward traversal can restore the exact policies a document held when it was originally loaded rather than re-deriving them from scratch — re-derivation would be wrong, since the originating response is long gone.
NavigationAction and NavigationType. Every navigation carries a descriptor of what caused it: link click, form submission, reload, BackForward traversal, and so on. DocumentWriter::begin() receives it as the optional triggeringAction parameter.
iframe sandbox. The attribute that applies sandbox flags to a framed document. With sandbox="allow-scripts", script runs but the document holds an opaque origin — it is same-origin with nothing.
WPT expectation baselines. WebKit checks in *-expected.txt files for imported web-platform-tests, and those baselines record currently-failing subtests verbatim, including assertion text. A rebaseline diff therefore shows precisely which spec behaviours a patch changed.
Analysis
This is a precedence bug: several legitimate sources of inherited policy exist, and the code chose between them by asking which one happened to be populated rather than which one the operation called for.
iframe with no src/srcdoc iframe srcdoc=""
───────────────────────── ────────────────────────
1. element inserted 1. element inserted
2. initial about:blank commits 2. parse-time srcdoc load
└─ HistoryItem created, └─ initiator = parent
policyContainer = empty policy = embedder CSP
3. script: iframe.srcdoc = ...
NavigationType != BackForward
└─ begin(): currentHistoryItem
non-null → HISTORY BRANCH ◄── wrong source
└─ document has no CSP
The left column of the diagram is the whole bug. A frame created with neither src nor srcdoc commits its initial empty document first, and that commit leaves the frame holding a current HistoryItem whose policy container describes the empty document — not the embedder. When script later assigns srcdoc, a fresh navigation to about:srcdoc begins with a NavigationType that is anything but BackForward. But the condition in DocumentWriter::begin() never looked at the type. It saw a non-null currentHistoryItem with a non-null policyContainer(), took the history branch, and called inheritPolicyContainerFrom() with the empty-document snapshot. The initiator's policy was never consulted, because control had already left the function's inheritance decision by the time any initiator-based path could run.
The right column is the control. iframe2 carries srcdoc="" at parse time, so its first real load is the srcdoc load; the inheritance resolves against the parent and the embedder's CSP lands correctly. The two <iframe> elements in the regression test differ by that one attribute and nothing else, which is a clean way of isolating the state divergence introduced by the initial empty document. That the history-item snapshot lacks the embedder's CSP is what the test outcome establishes: before the fix, iframe1's import succeeded; after it, both frames report loaded === false and the console logs matching refusals.
The consequence for the srcdoc document is total, not partial. It is not that a weaker policy applied — no policy applied. script-src and default-src were both absent, which is why the test's cross-origin import() of module-pass.py went through, and why the expectations file records a blocked stylesheet alongside the blocked scripts: the same missing container governs style-src too. Reaching this needs nothing exotic. An attacker with an HTML or script injection foothold on a CSP-protected page — exactly the situation CSP is deployed to contain — appends an iframe, assigns srcdoc, and has a policy-free execution context in which to load remote script and perform network egress. No memory-safety primitive is involved anywhere in the chain, and none is gained.
Two limits are worth stating precisely. The demonstrated case uses sandbox="allow-scripts", so the bypassing document holds an opaque origin and does not thereby obtain same-origin access to the embedder — the sandbox flags themselves travel in the policy container but were not what got bypassed here. If the same history-sourced inheritance were reachable for a srcdoc frame same-origin with its embedder (no sandbox attribute, or allow-same-origin), the policy-free document could then act with the embedder's origin; that extension is a projection, since both the commit title and the regression test scope the demonstrated bypass to sandboxed frames. And the whole thing lives inside the WebContent process: this is a same-process enforcement failure, so leaving the renderer still requires a separate memory-safety bug.
The WPT rebaseline settles the question of whether srcdoc was special. It was not. window.open(), contentWindow.location assignment, and both form-submission paths all reported "p" — the previous document — where the spec required the initiator's "a" or "b". Any navigation that reached DocumentWriter::begin() in a frame with a populated history snapshot got the snapshot. The single added conjunct fixes all five behaviours at once, because it restores the classification step the condition was missing: decide why this navigation is happening, and only then decide where its policy comes from.
A policy container saved for back/forward replay was applied to every navigation that found one present, so a script-assigned srcdoc inherited the initial empty document's empty CSP instead of the embedder's.
Insight
The FAIL ... expected "a" but got "p" lines sitting in inheritance-from-initiator.sub-expected.txt were a description of this security bug, checked into the tree, in plain English, for however long the baseline had been there. Someone had already done the spec comparison and recorded the delta; what was missing was anyone reading it as a bypass rather than as a known-failing conformance test. Committed FAIL expectations under security-relevant WPT directories are an underused bug oracle — the engine's own authors have pre-computed the gap between what the spec requires and what the engine does, and filed it in a file nobody greps.
Audit directions
-
Restore-from-snapshot state applied to operations that are not restores. The invariant: a snapshot captured for replay is authoritative only during replay; every other operation must re-derive state from the live actor. Narrow — grep
Source/WebCore/loaderandSource/WebCore/historyfor reads ofcurrentHistoryItem()/HistoryItem::policyContainer()and other restored fields that are not gated onNavigationType::BackForwardor an equivalent traversal flag; start withHistoryController,DocumentLoader, and the remaining branches ofDocumentWriter::begin(). Wider — the shape appears wherever a cached-state carrier is consulted by availability rather than by operation kind: bfcache restore paths,SubstituteData/FrameLoadType::Reloadhandling, process-swap state transfer. The tell in search results isif (savedThing && savedThing->field())with no accompanying check of why the operation is running. Widest — the principle holds in any system with session resumption or state hydration: Chromium'sPolicyContainerHostinheritance for local-scheme commits, server-side session resumption that replays a stored auth context onto a fresh request, SSR hydration that trusts serialized state over freshly computed state. The portable tell is a restore path whose guard tests whether saved state exists instead of whether a restore was requested. -
Every code path that creates a local-scheme document and decides its inherited security state. The invariant: a document with no response of its own must take its policy from its initiator or parent, and from nowhere else. Narrow — enumerate the creators for
about:blank,about:srcdoc,data:,blob:, andjavascript:in WebCore (DocumentWriter::begin(),DocumentWriter::replaceDocumentWithResultOfExecutingJavascriptURL(), initial-empty-document creation inFrameLoader,DOMImplementation::createDocumentcallers) and check for each whetherinheritPolicyContainerFrom/setContentSecurityPolicy/ origin inheritance resolves to the initiator. Wider — the class covers every inherited-by-construction security attribute, not just CSP: sandbox flags, referrer policy, COOP/COEP, andSecurityOriginPolicy(keep that distinct from theSecurityOriginit wraps). The shape to notice is a document-construction site reading inherited state from more than one candidate source with no explicit precedence comment. Widest — any system where a derived object inherits policy from a creator: OS process capability inheritance acrossfork/exec, container security contexts inherited by child namespaces, IAM role chaining. Same question every time — is the policy taken from the entity that actually requested creation, or from whatever context happened to be current? -
Checked-in WPT baselines as a bypass oracle. Narrow — search
LayoutTests/imported/w3c/web-platform-tests/content-security-policy/,.../fetch/metadata/,.../html/browsers/origin/, and.../permissions-policy/for*-expected.txtfiles containingFAIL, then read the assertion text; lines of the formexpected "<initiator>" but got "<something-else>"in an inheritance test name are direct statements that an inheritance source is wrong. Wider — the same triage applies toTestExpectationsentries markedFailurefor security tests and to-expected.txtfiles underhttp/tests/security; the tell is any recorded mismatch where the expected value is the stricter or more specific one. Ceiling — this rung is WebKit-baseline-specific in its file layout, so it does not port as a grep. The technique transfers to any project that commits known-failing test baselines, but the audit question stays the same: is any recorded failure a security invariant rather than a cosmetic delta? -
The initial empty document as a state carrier for later real navigations. The invariant: state recorded for a placeholder document must not be treated as the committed state of a subsequent real load. Narrow — trace when a frame's first
HistoryItemis created and what itspolicyContainer()holds for a frame created with neithersrcnorsrcdoc, then compare against the same frame after a parse-timesrcdoc=""; the observable difference between the two<iframe>elements in the new regression test is exactly this divergence, and any other per-frame state keyed on the current history item deserves the same comparison. Wider — look for other places where the synchronousabout:blankcommit seeds state that outlives it: encoding inheritance viacanReferToParentFrameEncoding,shouldReuseDefaultView/takeDOMWindowFromwindow reuse inDocumentWriter::begin(), document-lifecycle observers registered against the initial document. The shape to notice is any field populated during placeholder commit and read during a later real commit. Widest — this is the general "placeholder object's bookkeeping survives into the real object" class, recurring in lazily initialized singletons, default-constructed config objects that get partially overwritten, and connection pools that reuse a handshake context. The mental invariant: placeholder state should be provably discarded, not merely overwritten field by field.