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 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.

Changed Functions

FunctionChangeNotes
DocumentLoader::shouldClearContentSecurityPolicyForResponse
Source/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::responseReceived
Source/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.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/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"
Loading diff…

Original Bug Report

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