CVE-2024-23263
Overview
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.
Attack Path
- 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.
- 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>.
- Clear the policy DocumentLoader::responseReceived sees the null header on the later part and resets m_contentSecurityPolicy to null, dropping the sandbox policy.
- Execute blocked script The inline script in the later part runs despite the sandbox that should have blocked it, bypassing CSP.
Impact Assessment
Files Changed
LayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part-expected.txtLayoutTests/http/tests/security/contentSecurityPolicy/multipart-three-part.pyLayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part-expected.txtLayoutTests/http/tests/security/contentSecurityPolicy/multipart-two-part.pySource/WebCore/loader/DocumentLoader.cppSource/WebCore/loader/DocumentLoader.h
Audit Directions
- Same function: per-part response handlingAudit 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 patternsGrep loader code for ‘header.isNull() … = nullptr’ patterns where an absent header on a later response silently clears an already-applied policy.
Patch
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--'
+)
Regression Test / PoC
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--'
+)