Medium CVSS 6.5 webkit Bypass 🔧 Commit mapped

Overview

Medium
Severity
6.5
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may prevent Content Security Policy from being enforced
ComponentWebCore
Sub-componentWebCore Loader
Bug ClassBypass
Tracker264811
Fix commit93603245b1eb (WebKit/WebKit) +53/-4
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N
CISA KEVNot listed
CreditedJohan Carlsson (joaxcar)
Disclosed2024-03-05

Background

multipart/x-mixed-replace
A response type that streams multiple document ‘parts’, each replacing the previous, all within one main load handled by repeated responseReceived calls.
Content Security Policy (CSP) / sandbox
A policy delivered via response header that restricts script and other capabilities; the ‘sandbox’ directive can block script execution entirely.
DocumentLoader CSP setup
WebCore builds the document’s CSP from the main-resource response; clearing it on a later part removes the protection for that document.

Root Cause Analysis

This fixes a Content Security Policy enforcement bypass in multipart/x-mixed-replace document loads. DocumentLoader::responseReceived sets up the document’s CSP from each main-resource response: pre-patch, if the response carried a Content-Security-Policy header it (re)created m_contentSecurityPolicy and parsed the header; otherwise (header null) it set m_contentSecurityPolicy = nullptr, i.e. it CLEARED any existing policy. A multipart/x-mixed-replace response delivers several document ‘parts’ that replace each other, and responseReceived runs for each part. An attacker-controlled server could send the initial multipart response with a restrictive policy (the tests use ‘Content-Security-Policy: sandbox’) and then a SUBSEQUENT part with an empty/absent CSP header; the old logic saw the null header on the later part and reset m_contentSecurityPolicy to null, discarding the sandbox policy that the load began under. With the policy cleared, inline script in the later part executed even though sandbox (no allow-scripts) should have blocked it — a CSP/sandbox bypass.

The fix adds shouldClearContentSecurityPolicyForResponse(response), which returns true only when the CSP header isNull() AND !m_isLoadingMultipartContent, so during a multipart load an absent CSP header in a later part no longer clears the established policy. It also stops recreating the policy object when a header is present (if (!m_contentSecurityPolicy) makeUnique<ContentSecurityPolicy>(…)) and instead feeds headers via didReceiveHeaders onto the existing policy.

The restored invariant is that a subsequent multipart part cannot clear (or silently replace) the CSP applied to the multipart load. The regression tests deliver a sandboxed multipart document followed by a part with an empty CSP header and an inline alert(‘FAIL’), and expect the script to remain blocked.

Key insight
CSP was recomputed per multipart part and an absent header on a later part reset the policy to null; gating the clear on !m_isLoadingMultipartContent keeps a subsequent part from clearing the CSP established for the load.

Attack Path

  1. Serve a sandboxed multipart response An attacker-controlled endpoint returns Content-Type: multipart/x-mixed-replace with an initial part carrying Content-Security-Policy: sandbox.
  2. Send a later part with no CSP A subsequent multipart part is sent with an empty/absent Content-Security-Policy header and an inline <script>.
  3. Clear the policy DocumentLoader::responseReceived sees the null header on the later part and resets m_contentSecurityPolicy to null, dropping the sandbox policy.
  4. Execute blocked script The inline script in the later part runs despite the sandbox that should have blocked it, bypassing CSP.

Impact Assessment

A CSP/sandbox enforcement bypass confined to the WebContent process with no memory corruption: a multipart response can drop the sandbox/CSP mid-stream and run script that should be blocked, defeating an XSS/isolation mitigation. Real-world impact depends on sites (or intermediaries) that serve multipart content, but it undermines a core security control.

Files Changed

  • LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt
  • LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py
  • LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt
  • LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py
  • Source/WebCore/loader/DocumentLoader.cpp
  • Source/WebCore/loader/DocumentLoader.h

Audit Directions

  • Same function: per-part response handling
    Audit DocumentLoader::responseReceived and multipart handling (m_isLoadingMultipartContent, isMultipartReplacingLoad) for other security state (COOP/COEP, sandbox flags, referrer policy) recomputed or cleared per part.
  • Header-null-clears-policy patterns
    Grep loader code for ‘header.isNull() … = nullptr’ patterns where an absent header on a later response silently clears an already-applied policy.
diff --git a/Source/WebCore/loader/DocumentLoader.cpp b/Source/WebCore/loader/DocumentLoader.cpp
index 83b29cd1fb90..e85ff270c6f6 100644
--- a/Source/WebCore/loader/DocumentLoader.cpp
+++ b/Source/WebCore/loader/DocumentLoader.cpp
@@ -899,19 +899,26 @@ static URL microsoftTeamsRedirectURL()
     return URL { "https://www.microsoft.com/en-us/microsoft-365/microsoft-teams/"_str };
 }
 
+bool DocumentLoader::shouldClearContentSecurityPolicyForResponse(const ResourceResponse& response) const
+{
+    return response.httpHeaderField(HTTPHeaderName::ContentSecurityPolicy).isNull() && !m_isLoadingMultipartContent;
+}
+
 void DocumentLoader::responseReceived(CachedResource& resource, const ResourceResponse& response, CompletionHandler<void()>&& completionHandler)
 {
     ASSERT_UNUSED(resource, m_mainResource == &resource);
 
-    if (!response.httpHeaderField(HTTPHeaderName::ContentSecurityPolicy).isNull()) {
+    if (shouldClearContentSecurityPolicyForResponse(response))
+        m_contentSecurityPolicy = nullptr;
+    else {
         ReportingClient* reportingClient = nullptr;
         if (m_frame && m_frame->document())
             reportingClient = m_frame->document();
 
-        m_contentSecurityPolicy = makeUnique<ContentSecurityPolicy>(URL { response.url() }, nullptr, reportingClient);
+        if (!m_contentSecurityPolicy)
+            m_contentSecurityPolicy = makeUnique<ContentSecurityPolicy>(URL { response.url() }, nullptr, reportingClient);
         m_contentSecurityPolicy->didReceiveHeaders(ContentSecurityPolicyResponseHeaders { response }, m_request.httpReferrer(), ContentSecurityPolicy::ReportParsingErrors::No);
-    } else
-        m_contentSecurityPolicy = nullptr;
+    }
     if (m_frame && m_frame->document() && m_frame->document()->settings().crossOriginOpenerPolicyEnabled())
         m_responseCOOP = obtainCrossOriginOpenerPolicy(response);
 
diff --git a/Source/WebCore/loader/DocumentLoader.h b/Source/WebCore/loader/DocumentLoader.h
index e97e76a6d61b..875b1833d5c8 100644
--- a/Source/WebCore/loader/DocumentLoader.h
+++ b/Source/WebCore/loader/DocumentLoader.h
@@ -554,6 +554,8 @@ private:
     bool maybeLoadEmpty();
     void loadErrorDocument();
 
+    bool shouldClearContentSecurityPolicyForResponse(const ResourceResponse&) const;
+
     bool isMultipartReplacingLoad() const;
     bool isPostOrRedirectAfterPost(const ResourceRequest&, const ResourceResponse&);
 
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt
new file mode 100644
index 000000000000..b661747a18a8
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt
@@ -0,0 +1,6 @@
+CONSOLE MESSAGE: Blocked script execution in 'http://127.0.0.1:8000/security/contentSecurityPolicy/multipart-three-part.py' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
+layer at (0,0) size 800x600
+  RenderView at (0,0) size 800x600
+layer at (0,0) size 800x600
+  RenderBlock {HTML} at (0,0) size 800x600
+    RenderBody {BODY} at (8,8) size 784x584
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py
new file mode 100755
index 000000000000..83546ad50e7d
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.stdout.write(
+    'Content-Type: multipart/x-mixed-replace; boundary=TEST\r\n'
+    'Content-Security-Policy: sandbox\r\n\r\n'
+    '--TEST\r\n'
+    'Content-Type: text/html\r\n\r\n'
+    '--TEST\r\n'
+    'Content-Type: text/html\r\n'
+    'Content-Security-Policy:\r\n\r\n'
+    '<script>alert("FAIL")</script>\r\n'
+    '--TEST--'
+)
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt
new file mode 100644
index 000000000000..8390acf21238
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt
@@ -0,0 +1,6 @@
+CONSOLE MESSAGE: Blocked script execution in 'http://127.0.0.1:8000/security/contentSecurityPolicy/multipart-two-part.py' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
+layer at (0,0) size 800x600
+  RenderView at (0,0) size 800x600
+layer at (0,0) size 800x600
+  RenderBlock {HTML} at (0,0) size 800x600
+    RenderBody {BODY} at (8,8) size 784x584
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py
new file mode 100755
index 000000000000..97e1864c57b1
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.stdout.write(
+    'Content-Type: multipart/x-mixed-replace; boundary=TEST\r\n'
+    'Content-Security-Policy: sandbox\r\n\r\n'
+    '--TEST\r\n'
+    'Content-Type: text/html\r\n'
+    'Content-Security-Policy:\r\n\r\n'
+    '<script>alert("FAIL")</script>\r\n'
+    '--TEST--'
+)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt
new file mode 100644
index 000000000000..b661747a18a8
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txt
@@ -0,0 +1,6 @@
+CONSOLE MESSAGE: Blocked script execution in 'http://127.0.0.1:8000/security/contentSecurityPolicy/multipart-three-part.py' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
+layer at (0,0) size 800x600
+  RenderView at (0,0) size 800x600
+layer at (0,0) size 800x600
+  RenderBlock {HTML} at (0,0) size 800x600
+    RenderBody {BODY} at (8,8) size 784x584
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py
new file mode 100755
index 000000000000..83546ad50e7d
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.py
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.stdout.write(
+    'Content-Type: multipart/x-mixed-replace; boundary=TEST\r\n'
+    'Content-Security-Policy: sandbox\r\n\r\n'
+    '--TEST\r\n'
+    'Content-Type: text/html\r\n\r\n'
+    '--TEST\r\n'
+    'Content-Type: text/html\r\n'
+    'Content-Security-Policy:\r\n\r\n'
+    '<script>alert("FAIL")</script>\r\n'
+    '--TEST--'
+)
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt
new file mode 100644
index 000000000000..8390acf21238
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txt
@@ -0,0 +1,6 @@
+CONSOLE MESSAGE: Blocked script execution in 'http://127.0.0.1:8000/security/contentSecurityPolicy/multipart-two-part.py' because the document's frame is sandboxed and the 'allow-scripts' permission is not set.
+layer at (0,0) size 800x600
+  RenderView at (0,0) size 800x600
+layer at (0,0) size 800x600
+  RenderBlock {HTML} at (0,0) size 800x600
+    RenderBody {BODY} at (8,8) size 784x584
diff --git a/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py
new file mode 100755
index 000000000000..97e1864c57b1
--- /dev/null
+++ b/LayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.py
@@ -0,0 +1,13 @@
+#!/usr/bin/env python3
+
+import sys
+
+sys.stdout.write(
+    'Content-Type: multipart/x-mixed-replace; boundary=TEST\r\n'
+    'Content-Security-Policy: sandbox\r\n\r\n'
+    '--TEST\r\n'
+    'Content-Type: text/html\r\n'
+    'Content-Security-Policy:\r\n\r\n'
+    '<script>alert("FAIL")</script>\r\n'
+    '--TEST--'
+)
Loading diff…

Original Bug Report

The reporter's bug is still restricted on the tracker.