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
Changed Functions
| Function | Change | Notes |
|---|---|---|
DocumentLoader::shouldClearContentSecurityPolicyForResponseSource/WebCore/loader/DocumentLoader.cpp |
added | Returns true only when the response has no CSP header AND !m_isLoadingMultipartContent, so a later multipart part with an absent header cannot clear the load's CSP. |
DocumentLoader::responseReceivedSource/WebCore/loader/DocumentLoader.cpp |
modified | Clears CSP only via shouldClearContentSecurityPolicyForResponse; otherwise reuses the existing m_contentSecurityPolicy (creating it only if absent) and merges headers via didReceiveHeaders instead of recreating/clearing it per part. |
DocumentLoader::shouldClearContentSecurityPolicyForResponse (declaration)Source/WebCore/loader/DocumentLoader.h |
modified | Declares the new helper. |
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/Tools/Scripts/update-angle b/Tools/Scripts/update-angle
index ef380ec9a7f7..d8f402d3a9e8 100755
--- a/Tools/Scripts/update-angle
+++ b/Tools/Scripts/update-angle
@@ -2,6 +2,7 @@
set -e
cd "$(dirname "$0")/../../Source/ThirdParty/ANGLE"
ANGLE_DIR="$PWD"
+ANGLE_TARGET_COMMIT="origin/main"
regenerate_changes_diff() {
echo "Regenerating changes.diff."
@@ -42,11 +43,12 @@ regenerate_program_version_id() {
usage() {
SCRIPT_NAME=$(basename "$0")
- echo "USAGE: $SCRIPT_NAME [-h|--help] -[--regenerate-changes-diff[-main]]"
+ echo "USAGE: $SCRIPT_NAME [-h|--help] -[--regenerate-changes-diff[-main]] [commit]"
echo " -h | --help Print this help message."
echo " --regenerate-changes-diff Regenerate ANGLE changes.diff to last upstream merge."
echo " --regenerate-changes-diff-main Regenerate ANGLE changes.diff to upstream origin/main."
echo " --regenerate-program-version-id Regenerate ANGLE ANGLEShaderProgramVersion.h."
+ echo " commit The ANGLE commit to update to. Defaults to origin/main"
}
if [ ! -z "$1" ] ; then
@@ -90,10 +92,14 @@ if [ ! -z "$1" ] ; then
echo
echo "Success."
exit 0
+
+ elif [ "${1:0:2}" = -- ]; then
+ echo "ERROR: Unrecognized argument: $1"
+ usage
+ exit 1
+ else
+ ANGLE_TARGET_COMMIT="$1"
fi
- echo "ERROR: Unrecognized argument: $1"
- usage
- exit 1
fi
echo "This script helps you update the copy of ANGLE in Source/ThirdParty/ANGLE"
@@ -136,7 +142,7 @@ wait_for_rebase_to_complete() {
cleanup_after_successful_rebase_and_exit() {
cd "$ANGLE_DIR"
echo
- regenerate_changes_diff "origin/main"
+ regenerate_changes_diff "$ANGLE_TARGET_COMMIT"
git --no-pager diff -b --cached "$LAST_ROLL_COMMIT_HASH" -- Compiler.cmake GLESv2.cmake
echo
echo "Rebase complete!"
@@ -155,7 +161,7 @@ cleanup_after_successful_rebase_and_exit() {
echo "Press Enter to continue after fixing build:"
read -r
regenerate_program_version_id
- regenerate_changes_diff "origin/main"
+ regenerate_changes_diff "$ANGLE_TARGET_COMMIT"
echo "Generating contents of commit message into commit-message.txt."
echo "Be sure to copy out this file's contents and delete it before committing."
echo "Update ANGLE to $(git log -1 ${COMMIT_HASH} --format=%cs) (${COMMIT_HASH}))" > commit-message.txt
@@ -196,8 +202,9 @@ cd "$ANGLE_DIR"
echo "Downloading latest ANGLE via git clone."
# Remove all files including hidden ones, but not . or ..
rm -rf ..?* .[!.]* ./*
-git clone --branch main https://chromium.googlesource.com/angle/angle .
+git clone https://chromium.googlesource.com/angle/angle .
echo "Successfully downloaded latest ANGLE."
+git checkout -q "$ANGLE_TARGET_COMMIT"
echo "Commit hash: "
COMMIT_HASH=$(git rev-parse HEAD)
echo "$COMMIT_HASH"
@@ -222,7 +229,7 @@ sed -i.bak -e "s/<string>[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]<\/string>/<s
rm ANGLE.plist.bak
echo "Translating gni build files to cmake."
-git checkout origin/main -- src/compiler.gni src/libGLESv2.gni src/libANGLE/renderer/d3d/BUILD.gn
+git checkout "$ANGLE_TARGET_COMMIT" -- src/compiler.gni src/libGLESv2.gni src/libANGLE/renderer/d3d/BUILD.gn
./gni-to-cmake.py src/compiler.gni Compiler.cmake
./gni-to-cmake.py src/libGLESv2.gni GLESv2.cmake
./gni-to-cmake.py src/libANGLE/renderer/d3d/BUILD.gn D3D.cmake --prepend 'src/libANGLE/renderer/d3d/'
@@ -244,7 +251,7 @@ git replace --graft "$LAST_ROLL_COMMIT_HASH" "$PREVIOUS_ANGLE_COMMIT_HASH"
git checkout -b rebased-webkit-changes
echo "Rebasing WebKit's local changes on latest ANGLE main."
-if ! git rebase origin/main; then
+if ! git rebase "$ANGLE_TARGET_COMMIT"; then
echo
echo "There is now a temporary git repo in Source/ThirdParty/ANGLE with a"
echo "rebase in progress. You must resolve the merge conflict and continue"