Released March 24, 2026

CVE-2026-20665

CVSS 6.5 severity medium CWE-693 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:L

Overview

Impact:

Description: Processing maliciously crafted web content may prevent Content Security Policy from being enforced

Researchers: webb

Key insight. A security-context inheritance path was gated only on the existence of a cached HistoryItem policy container, not on the navigation actually being a back/forward traversal, so ordinary (non-history) loads such as dynamically-set srcdoc iframes silently adopted a stale/permissive policy container instead of the parent’s CSP.

Patch Details

diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt
new file mode 100644
index 000000000000..fdd0c9b0110f
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt
@@ -0,0 +1,14 @@
+CONSOLE MESSAGE: Refused to apply a stylesheet because its hash, its nonce, or 'unsafe-inline' appears in neither the style-src directive nor the default-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py because it does not appear in the script-src directive of the Content Security Policy.
+This test checks if iframes without an initial srcdoc attribute incorrectly bypass CSP when srcdoc is set later.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS iframe1Loaded is false
+PASS iframe2Loaded is false
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
new file mode 100644
index 000000000000..5d45317025b9
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';">
+<script src="/js-test-resources/js-test.js"></script>
+</head>
+<body>
+<script>
+description("This test checks if iframes without an initial srcdoc attribute incorrectly bypass CSP when srcdoc is set later.");
+
+window.jsTestIsAsync = true;
+
+let resultsReceived = 0;
+let iframe1Loaded;
+let iframe2Loaded;
+
+function reportResult(iframeId, loaded) {
+    if (iframeId === "iframe1")
+        iframe1Loaded = loaded;
+    else
+        iframe2Loaded = loaded;
+    resultsReceived++;
+
+    if (resultsReceived === 2)
+        finishTest();
+}
+
+function finishTest() {
+    shouldBeFalse("iframe1Loaded");
+    shouldBeFalse("iframe2Loaded");
+    finishJSTest();
+}
+
+window.addEventListener("message", (event) => {
+    if (event.data.type === "import-result")
+        reportResult(event.data.iframe, event.data.loaded);
+});
+
+document.addEventListener("DOMContentLoaded", function() {
+    const iframe1 = document.getElementById("iframe1");
+    const iframe2 = document.getElementById("iframe2");
+    const srcdocContent = `
+        <script>
+        (async function() {
+            try {
+                await import('https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py');
+                window.parent.postMessage({type: 'import-result', iframe: '${iframe1.id}', loaded: true}, '*');
+            } catch (e) {
+                window.parent.postMessage({type: 'import-result', iframe: '${iframe1.id}', loaded: false}, '*');
+            }
+        })();
+        <\/script>
+    `;
+
+    iframe1.srcdoc = srcdocContent;
+    iframe2.srcdoc = srcdocContent.replaceAll(iframe1.id, iframe2.id);
+});
+</script>
+<iframe id="iframe1" sandbox="allow-scripts"></iframe>
+<iframe id="iframe2" sandbox="allow-scripts" srcdoc=""></iframe>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py b/LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py
new file mode 100755
index 000000000000..e9fe8b096842
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.stdout.write(
+    "Access-Control-Allow-Origin: *\r\n"
+    "Content-Type: application/javascript\r\n\r\n"
+)
diff --git a/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt b/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
index aa86667a552d..7fe1e76a28a2 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
+++ b/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
@@ -1,10 +1,10 @@
 
 
 PASS Setting src inherits from parent.
-FAIL Changing contentWindow.location inherits from who changed it. assert_equals: expected "a" but got "p"
+PASS Changing contentWindow.location inherits from who changed it.
 PASS Changing contentWindow.location indirectly inherits from who changed it directly.
-FAIL window.open() inherits from caller. assert_equals: expected "a" but got "p"
+PASS window.open() inherits from caller.
 PASS Click on anchor inherits from owner of the anchor.
-FAIL Form submission through submit() inherits from owner of form. assert_equals: expected "b" but got "p"
-FAIL Form submission through button click inherits from owner of form. assert_equals: expected "b" but got "p"
+PASS Form submission through submit() inherits from owner of form.
+PASS Form submission through button click inherits from owner of form.
 
diff --git a/Source/WebCore/loader/DocumentWriter.cpp b/Source/WebCore/loader/DocumentWriter.cpp
index 582f47b89eb8..e0ba1bf687fc 100644
--- a/Source/WebCore/loader/DocumentWriter.cpp
+++ b/Source/WebCore/loader/DocumentWriter.cpp
@@ -228,7 +228,7 @@ bool DocumentWriter::begin(const URL& urlReference, bool dispatch, Document* own
             return document->loader() && document->loader()->substituteData().isValid();
         };
 
-        if (currentHistoryItem && currentHistoryItem->policyContainer()) {
+        if (triggeringAction && triggeringAction->type() == NavigationType::BackForward && currentHistoryItem && currentHistoryItem->policyContainer()) {
             const auto& policyContainerFromHistory = currentHistoryItem->policyContainer();
             ASSERT(policyContainerFromHistory);
             document->inheritPolicyContainerFrom(*policyContainerFromHistory);

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2026-20665
Bugzilla 304951
Component WebCore
Bug Class Bypass
Severity medium
Commit b4390e8352b7dc2e…
Advisory Apple Advisory

Terminology

  • Policy container — A per-document bundle of security context (Content Security Policy, referrer policy, cross-origin settings) that WebKit inherits or restores when a document is created.
  • Content Security Policy (CSP) — A policy delivered via header or meta tag that restricts which origins scripts, styles, and other resources may load from, mitigating injection attacks.
  • srcdoc iframe — An iframe whose document is generated from an inline HTML string rather than fetched over the network, and which must inherit its security context (including CSP) from the embedding/parent document.
  • HistoryItem policy container — A cached copy of a document’s policy container stored on a session-history entry so the exact prior security context can be restored during back/forward navigation.
  • NavigationType::BackForward — The navigation type flag indicating a history traversal (back/forward), the only case in which restoring a document’s security context from a stored HistoryItem is correct.

Root Cause Analysis

The patch modifies DocumentWriter::begin, which is responsible for initializing a new Document’s security state as a load commits. Before the fix, the code unconditionally inherited the Document’s policy container (which carries the Content Security Policy, referrer policy, and related security context) from currentHistoryItem whenever that history item existed and had a stored policy container: ‘if (currentHistoryItem && currentHistoryItem->policyContainer())’. The invariant that was violated is that a policy container should only be resurrected from a HistoryItem when the navigation is actually a history traversal (Back/Forward), where restoring the exact prior security context is correct. For all other navigation types the new document must derive its policy container from the correct live source (for srcdoc iframes and other locally-generated documents, from the parent/initiator document). Because the guard did not check the navigation type, an iframe that happened to have a stale or attacker-influenced currentHistoryItem could have its CSP overwritten by whatever policy container was cached in that history item, rather than inheriting the parent frame’s restrictive CSP. The included layout test demonstrates the concrete failure: an iframe with sandbox=‘allow-scripts’ whose srcdoc is assigned dynamically (rather than present as an initial attribute) would end up NOT enforcing the parent’s ‘script-src self’ policy, so a dynamic import() of a cross-origin module that should be blocked was instead allowed to load. The fix restores the invariant by gating the history-based inheritance on ’triggeringAction && triggeringAction->type() == NavigationType::BackForward’, so history policy containers are only reapplied during genuine back/forward navigations; every other case falls through to the normal initiator-based inheritance and the parent CSP is correctly propagated into the srcdoc document. The updated WPT expectation file (inheritance-from-initiator) flipping four FAIL results to PASS confirms the general bug: policy/context inheritance was being taken from the wrong source (history ‘p’ instead of the initiator ‘a’/‘b’) for location changes, window.open, and form submissions.

Attack Path

1. Serve an attacker page with a restrictive CSP

The top document sets ‘Content-Security-Policy: default-src self; script-src self …’ (as in the test), which should be inherited by locally-generated child documents such as srcdoc iframes.

2. Create a srcdoc iframe without an initial srcdoc attribute

Insert an <iframe sandbox=‘allow-scripts’> that has no srcdoc attribute at parse time, so its associated history item / initial empty-document security state is established before any srcdoc content exists.

3. Assign srcdoc dynamically after load

Set iframe.srcdoc = ‘<script>…import(…)</script>’ from script. This triggers a new document load in the frame that goes through DocumentWriter::begin with a currentHistoryItem that carries a cached policy container but is NOT a BackForward navigation.

4. Cause the wrong policy container to be inherited

The pre-patch unconditional branch copies the history item’s policy container into the new srcdoc document, discarding the parent’s CSP so the srcdoc document runs with a missing or permissive policy.

5. Execute content the parent CSP forbids

Inside the srcdoc document, perform an action the parent policy should block — e.g. dynamic import() of a cross-origin module (module-pass.py) or injection of inline styles/scripts. With CSP not enforced, the load succeeds, defeating the site’s script-src restriction.

6. Leverage the bypass

Use the now-unrestricted execution context to load attacker-controlled script/modules that the deploying site intended CSP to block, undermining injection defenses (e.g. turning an otherwise-contained HTML injection into script execution).

Impact Assessment

This is a logic/security-policy bypass, not a memory-corruption primitive: the flaw causes the wrong CSP (or none) to be enforced on locally-generated documents such as dynamically-assigned srcdoc iframes. It does not by itself grant read/write of memory, but it neutralizes a site’s script-src/default-src defenses, so any secondary HTML-injection or content-injection vector on the victim site can escalate into full script execution in that document’s context, enabling data theft or cross-origin resource loads the policy was meant to block. The effect is confined to the WebContent process security model (same-origin/CSP enforcement); there is no direct sandbox escape or process-privilege gain from this bug alone.

Audit Directions

Other policy-container inheritance branches in DocumentWriter

In DocumentWriter.cpp and DocumentLoader, audit every call to inheritPolicyContainerFrom and every read of currentHistoryItem()->policyContainer() to confirm each is guarded by the correct navigation type; grep for ‘policyContainer’, ‘inheritPolicyContainerFrom’, and ‘currentHistoryItem’.

Initial-empty-document and about:blank/about:srcdoc contexts

Check that documents created for about:blank, about:srcdoc, javascript: URLs, and synchronously-committed loads derive CSP/policy from the initiator rather than any cached history state; grep for ‘isSrcdocDocument’, ‘aboutSrcDoc’, ‘shouldInheritSecurityOriginFromOwner’.

Search for other conditionals that restore per-history security state (referrer policy, sandbox flags, COOP/COEP) without checking NavigationType::BackForward; grep ‘NavigationType::BackForward’, ’triggeringAction’, and history-item restore paths to find analogous missing guards.

Changed Functions

Function File Change Note
DocumentWriter::begin Source/WebCore/loader/DocumentWriter.cpp modified Added a guard so a policy container is inherited from currentHistoryItem only when triggeringAction is a NavigationType::BackForward navigation; otherwise normal initiator-based policy-container inheritance applies, preserving the parent frame’s CSP for dynamically-set srcdoc documents.

Files Changed

Source Files

  • Source/WebCore/loader/DocumentWriter.cpp

Test Files

  • LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt
  • LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
  • LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py
  • LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt
new file mode 100644
index 000000000000..fdd0c9b0110f
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass-expected.txt
@@ -0,0 +1,14 @@
+CONSOLE MESSAGE: Refused to apply a stylesheet because its hash, its nonce, or 'unsafe-inline' appears in neither the style-src directive nor the default-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py because it does not appear in the script-src directive of the Content Security Policy.
+CONSOLE MESSAGE: Refused to load https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py because it does not appear in the script-src directive of the Content Security Policy.
+This test checks if iframes without an initial srcdoc attribute incorrectly bypass CSP when srcdoc is set later.
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS iframe1Loaded is false
+PASS iframe2Loaded is false
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
new file mode 100644
index 000000000000..5d45317025b9
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/iframe-srcdoc-import-bypass.html
@@ -0,0 +1,62 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval';">
+<script src="/js-test-resources/js-test.js"></script>
+</head>
+<body>
+<script>
+description("This test checks if iframes without an initial srcdoc attribute incorrectly bypass CSP when srcdoc is set later.");
+
+window.jsTestIsAsync = true;
+
+let resultsReceived = 0;
+let iframe1Loaded;
+let iframe2Loaded;
+
+function reportResult(iframeId, loaded) {
+    if (iframeId === "iframe1")
+        iframe1Loaded = loaded;
+    else
+        iframe2Loaded = loaded;
+    resultsReceived++;
+
+    if (resultsReceived === 2)
+        finishTest();
+}
+
+function finishTest() {
+    shouldBeFalse("iframe1Loaded");
+    shouldBeFalse("iframe2Loaded");
+    finishJSTest();
+}
+
+window.addEventListener("message", (event) => {
+    if (event.data.type === "import-result")
+        reportResult(event.data.iframe, event.data.loaded);
+});
+
+document.addEventListener("DOMContentLoaded", function() {
+    const iframe1 = document.getElementById("iframe1");
+    const iframe2 = document.getElementById("iframe2");
+    const srcdocContent = `
+        <script>
+        (async function() {
+            try {
+                await import('https://localhost:8443/security/contentSecurityPolicy/resources/module-pass.py');
+                window.parent.postMessage({type: 'import-result', iframe: '${iframe1.id}', loaded: true}, '*');
+            } catch (e) {
+                window.parent.postMessage({type: 'import-result', iframe: '${iframe1.id}', loaded: false}, '*');
+            }
+        })();
+        <\/script>
+    `;
+
+    iframe1.srcdoc = srcdocContent;
+    iframe2.srcdoc = srcdocContent.replaceAll(iframe1.id, iframe2.id);
+});
+</script>
+<iframe id="iframe1" sandbox="allow-scripts"></iframe>
+<iframe id="iframe2" sandbox="allow-scripts" srcdoc=""></iframe>
+</body>
+</html>
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py b/LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py
new file mode 100755
index 000000000000..e9fe8b096842
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/resources/module-pass.py
@@ -0,0 +1,8 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.stdout.write(
+    "Access-Control-Allow-Origin: *\r\n"
+    "Content-Type: application/javascript\r\n\r\n"
+)
diff --git a/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt b/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
index aa86667a552d..7fe1e76a28a2 100644
--- a/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
+++ b/LayoutTests/imported/w3c/web-platform-tests/content-security-policy/inheritance/inheritance-from-initiator.sub-expected.txt
@@ -1,10 +1,10 @@
 
 
 PASS Setting src inherits from parent.
-FAIL Changing contentWindow.location inherits from who changed it. assert_equals: expected "a" but got "p"
+PASS Changing contentWindow.location inherits from who changed it.
 PASS Changing contentWindow.location indirectly inherits from who changed it directly.
-FAIL window.open() inherits from caller. assert_equals: expected "a" but got "p"
+PASS window.open() inherits from caller.
 PASS Click on anchor inherits from owner of the anchor.
-FAIL Form submission through submit() inherits from owner of form. assert_equals: expected "b" but got "p"
-FAIL Form submission through button click inherits from owner of form. assert_equals: expected "b" but got "p"
+PASS Form submission through submit() inherits from owner of form.
+PASS Form submission through button click inherits from owner of form.