Medium CVSS 6.1 webkit Cross Origin 🔧 Commit mapped

Overview

Medium
Severity
6.1
CVSS
No
Exploited ITW
Fixed
Fix Status
DescriptionProcessing maliciously crafted web content may lead to universal cross site scripting
ComponentWebKit
Sub-componentWebKit UIProcess
Bug ClassCross Origin
Tracker285927
Fix commitfeb7725d8aed (WebKit/WebKit) +43/-0
CWECWE-79 (Cross-site scripting)
CVSS vectorCVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N
CISA KEVNot listed
CreditedMartin Bajanik of Fingerprint, Ammar Askar
Disclosed2025-07-29

Background

com.apple.quarantine / Gatekeeper
A macOS extended attribute set on files from untrusted sources, plus the Gatekeeper mechanism that requires explicit user approval before such content from an unidentified developer is opened.
Web Archive (.webarchive)
A single-file page snapshot whose main resource can claim an arbitrary origin and contain script, making its execution equivalent to running attacker code as a spoofed origin.
UXSS (universal cross-site scripting)
A bug class where attacker script bypasses the same-origin policy and runs against or reads state of arbitrary origins.
Provisional navigation cancellation
Failing an in-progress navigation by delivering a Cancellation-type ResourceError to didFailProvisionalNavigationWithError, which aborts the load before content is committed.

Root Cause Analysis

This is a Gatekeeper/quarantine-bypass logic bug on macOS in the Web Archive loading path that leads to universal cross-site scripting (UXSS). A .webarchive main resource can declare an arbitrary origin and carry script, so opening one runs content with the authority of spoofed origins; macOS normally protects users from such downloaded content via the com.apple.quarantine extended attribute and Gatekeeper’s “unidentified developer” approval prompt. The violated invariant is that a quarantined, not-yet-user-approved local .webarchive must not be silently loaded and executed. This CVE is a follow-up to the web-archive isolation work (CVE-2024-40857): that patch made WebPageProxy::receivedNavigationActionPolicyDecision() swap in a non-persistent data store for substitute or file:// .webarchive Use decisions, but it did not check the file’s quarantine state, so a webarchive delivered to disk and reached via a file:// navigation (for example a link click, as the added test exercises) would load and execute despite being quarantined and unapproved.

The fix adds, under PLATFORM(MAC), a guard inside that same branch: for a non-substitute (file://) web archive, if isQuarantinedAndNotUserApproved(webarchiveURL.fileSystemPath()) is true, it logs, builds a cancellation ResourceError, reports didFailProvisionalNavigationWithError, and returns before any data-store swap or load — cancelling the navigation exactly as Gatekeeper intends. The accompanying layout test loads a page that programmatically clicks a link to a quarantined .webarchive and asserts the load is cancelled, and mac.py is changed to set the com.apple.quarantine xattr on the test resource so the quarantine path is actually exercised. Note: isQuarantinedAndNotUserApproved(), cancelledError(), and the exact ResourceError plumbing live outside the shown diff, so their internals are inferred from the call site rather than shown by the patch.

Key insight
The fix closes a gap in the web-archive isolation work: local .webarchive loads honored the isolation data-store swap but not macOS quarantine/Gatekeeper, so a downloaded, unapproved archive could still execute and achieve UXSS; the guard now cancels the navigation when the file is quarantined and not user-approved.

Attack Path

  1. Deliver a quarantined web archive The attacker gets a crafted .webarchive onto the victim’s disk through a channel that sets com.apple.quarantine (download, email attachment, messaging), with a main resource spoofing a sensitive origin and embedding script.
  2. Induce a file:// navigation to it The victim (or attacker-controlled content, as the test’s document.querySelector(‘a’).click() shows) navigates to the local .webarchive via a file:// URL ending in .webarchive.
  3. Pre-patch: load proceeds despite quarantine receivedNavigationActionPolicyDecision reaches the web-archive branch and, without a quarantine check, swaps in the data store and loads the archive, running its script without the Gatekeeper unidentified-developer approval.
  4. UXSS execution The archive’s script executes as its spoofed origin, giving universal cross-site scripting against arbitrary sites.
  5. Fix cancels the load Post-patch, isQuarantinedAndNotUserApproved() causes the navigation to fail with a cancellation error before any load, so an unapproved quarantined archive never executes.

Impact Assessment

This is a same-origin-policy bypass (UXSS) gated on a quarantine/Gatekeeper check, not a memory-safety primitive: the diff contains no allocation or bounds logic and cannot be groomed toward memory corruption. Exploited pre-patch, a quarantined .webarchive that the user was never prompted to approve executes script as spoofed origins, a confidentiality/integrity break in the WebContent process and browsing profile rather than a route to native code execution. Because it requires getting a quarantined archive onto disk and a file:// navigation to it, real-world exploitation depends on delivery and some user interaction, consistent with the medium rating.

Changed Functions

FunctionChangeNotes
MacCatalystPort
Tools/Scripts/webkitpy/port/mac.py
modified
if
LayoutTests/webarchive/loading/test-loading-archive-with-link.html
modified

Files Changed

  • LayoutTests/platform/mac-wk1/TestExpectations
  • LayoutTests/webarchive/loading/resources/quarantined_top.webarchive
  • LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt
  • LayoutTests/webarchive/loading/test-loading-archive-with-link.html
  • Source/WebKit/UIProcess/WebPageProxy.cpp
  • Tools/Scripts/webkitpy/port/mac.py

Audit Directions

  • Other content types reachable via file:// that skip quarantine
    In receivedNavigationActionPolicyDecision and nearby load paths, check whether the isQuarantinedAndNotUserApproved() check covers all archive/executable-content branches (substitute-data archives, back/forward reloads, non-.webarchive extensions); grep isQuarantinedAndNotUserApproved, protocolIsFile, .webarchive.
  • Quarantine enforcement across ports/platforms
    The guard is PLATFORM(MAC)-only; audit whether iOS/Catalyst or other file-load entry points need equivalent quarantine/approval checks, and whether loadFile / substitute-data paths bypass it entirely.
  • Consistency with disallowWebArchive gating
    Cross-check the UIProcess quarantine cancel against WebCore’s DocumentLoader::disallowWebArchive / allowsWebArchiveForMainFrame and AlwaysAllowLocalWebarchive to ensure no combination of settings re-enables an unapproved local archive; grep disallowWebArchive, alwaysAllowLocalWebarchive, dataStoreForWebArchive.
diff --git a/Source/WebKit/UIProcess/WebPageProxy.cpp b/Source/WebKit/UIProcess/WebPageProxy.cpp
index c42687a93ccd..8f6f94d7bb5e 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.cpp
+++ b/Source/WebKit/UIProcess/WebPageProxy.cpp
@@ -4922,6 +4922,15 @@ void WebPageProxy::receivedNavigationActionPolicyDecision(WebProcessProxy& proce
         bool isSubstituteWebArchive = navigation->substituteData() && MIMETypeRegistry::isWebArchiveMIMEType(navigation->substituteData()->MIMEType);
         auto webarchiveURL = isSubstituteWebArchive ? URL { navigation->substituteData()->baseURL } : navigation->currentRequest().url();
         if (isSubstituteWebArchive || (webarchiveURL.protocolIsFile() && webarchiveURL.fileSystemPath().endsWith(".webarchive"_s))) {
+#if PLATFORM(MAC)
+            if (!isSubstituteWebArchive && isQuarantinedAndNotUserApproved(webarchiveURL.fileSystemPath())) {
+                WEBPAGEPROXY_RELEASE_LOG(Loading, "receivedNavigationActionPolicyDecision: file cannot be opened because it is from an unidentified developer.");
+                auto error = WebKit::cancelledError(URL { navigation->currentRequest().url() });
+                error.setType(WebCore::ResourceError::Type::Cancellation);
+                m_navigationClient->didFailProvisionalNavigationWithError(*this, FrameInfoData { frameInfo }, navigation, requestURL, error, nullptr);
+                return;
+            }
+#endif
             WEBPAGEPROXY_RELEASE_LOG(Loading, "receivedNavigationActionPolicyDecision: Swapping in non-persistent websiteDataStore for web archive.");
             if (navigation->targetItem() && navigation->targetItem()->dataStoreForWebArchive())
                 websiteDataStore = *navigation->targetItem()->dataStoreForWebArchive();
diff --git a/Tools/Scripts/webkitpy/port/mac.py b/Tools/Scripts/webkitpy/port/mac.py
index e11124a48048..2a9b92d0d729 100644
--- a/Tools/Scripts/webkitpy/port/mac.py
+++ b/Tools/Scripts/webkitpy/port/mac.py
@@ -349,6 +349,8 @@ class MacPort(DarwinPort):
             _log.debug('Warming up the runner ...')
             warmup_driver = self.create_driver(0)
             warmup_driver.run_test(DriverInput('file:///warmup-does-not-exist', 60000., None, should_run_pixel_test=False), stop_when_done=True)
+        # rdar://132098879, ensure webarchive is quarantined.
+        self.host.executive.run_command(['/usr/bin/xattr', self.layout_tests_dir() + '/webarchive/loading/resources/quarantined_top.webarchive']).rstrip()
 
 
 class MacCatalystPort(MacPort):
diff --git a/LayoutTests/platform/mac-wk1/TestExpectations b/LayoutTests/platform/mac-wk1/TestExpectations
index 52e14e87eb4e..70e2e5b03b61 100644
--- a/LayoutTests/platform/mac-wk1/TestExpectations
+++ b/LayoutTests/platform/mac-wk1/TestExpectations
@@ -1733,6 +1733,8 @@ webkit.org/b/244308 imported/w3c/web-platform-tests/cookies/attributes/attribute
 # Only flakey in WK1
 webarchive/loading/test-loading-archive-subresource-null-mimetype.html [ Failure Pass ]
 
+webarchive/loading/test-loading-archive-with-link.html [ Skip ]
+
 # WebKit1 frames use native scrollbars causing these tests to fail.
 
 # Lack of ways to control the cross-frame scroll chaining in WK1.
diff --git a/LayoutTests/webarchive/loading/resources/quarantined_top.webarchive b/LayoutTests/webarchive/loading/resources/quarantined_top.webarchive
new file mode 100644
index 000000000000..c636935d035c
Binary files /dev/null and b/LayoutTests/webarchive/loading/resources/quarantined_top.webarchive differ
diff --git a/LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt b/LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt
new file mode 100644
index 000000000000..2779121027bf
--- /dev/null
+++ b/LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt
@@ -0,0 +1,7 @@
+main frame - didStartProvisionalLoadForFrame
+main frame - didCommitLoadForFrame
+main frame - didFinishDocumentLoadForFrame
+main frame - didHandleOnloadEventsForFrame
+main frame - didFinishLoadForFrame
+test-loading-archive-with-link.html - didFinishLoading
+Click me This tests that clicking a link to a webarchive is cancelled.
diff --git a/LayoutTests/webarchive/loading/test-loading-archive-with-link.html b/LayoutTests/webarchive/loading/test-loading-archive-with-link.html
new file mode 100644
index 000000000000..08366f9b9e5c
--- /dev/null
+++ b/LayoutTests/webarchive/loading/test-loading-archive-with-link.html
@@ -0,0 +1,23 @@
+<html><!-- webkit-test-runner [ AlwaysAllowLocalWebarchive=true ] -->
+<script>
+    if (window.testRunner) {
+        testRunner.dumpResourceLoadCallbacks();
+        testRunner.dumpAsText();
+        testRunner.waitUntilDone();
+    }
+    if (window.internals)
+        internals.setAlwaysAllowLocalWebarchive(true);
+    function timedout() {
+        if (window.testRunner)
+            testRunner.notifyDone();
+    }
+</script>
+<body>
+    <a href="resources/quarantined_top.webarchive" target="_blank">Click me</a>
+    <script>
+    document.querySelector("a").click()
+    setTimeout(timedout, 100);
+    </script>
+    This tests that clicking a link to a webarchive is cancelled.
+</body>
+</html>
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/LayoutTests/platform/mac-wk1/TestExpectations b/LayoutTests/platform/mac-wk1/TestExpectations
index 52e14e87eb4e..70e2e5b03b61 100644
--- a/LayoutTests/platform/mac-wk1/TestExpectations
+++ b/LayoutTests/platform/mac-wk1/TestExpectations
@@ -1733,6 +1733,8 @@ webkit.org/b/244308 imported/w3c/web-platform-tests/cookies/attributes/attribute
 # Only flakey in WK1
 webarchive/loading/test-loading-archive-subresource-null-mimetype.html [ Failure Pass ]
 
+webarchive/loading/test-loading-archive-with-link.html [ Skip ]
+
 # WebKit1 frames use native scrollbars causing these tests to fail.
 
 # Lack of ways to control the cross-frame scroll chaining in WK1.
diff --git a/LayoutTests/webarchive/loading/resources/quarantined_top.webarchive b/LayoutTests/webarchive/loading/resources/quarantined_top.webarchive
new file mode 100644
index 000000000000..c636935d035c
Binary files /dev/null and b/LayoutTests/webarchive/loading/resources/quarantined_top.webarchive differ
diff --git a/LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt b/LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt
new file mode 100644
index 000000000000..2779121027bf
--- /dev/null
+++ b/LayoutTests/webarchive/loading/test-loading-archive-with-link-expected.txt
@@ -0,0 +1,7 @@
+main frame - didStartProvisionalLoadForFrame
+main frame - didCommitLoadForFrame
+main frame - didFinishDocumentLoadForFrame
+main frame - didHandleOnloadEventsForFrame
+main frame - didFinishLoadForFrame
+test-loading-archive-with-link.html - didFinishLoading
+Click me This tests that clicking a link to a webarchive is cancelled.
diff --git a/LayoutTests/webarchive/loading/test-loading-archive-with-link.html b/LayoutTests/webarchive/loading/test-loading-archive-with-link.html
new file mode 100644
index 000000000000..08366f9b9e5c
--- /dev/null
+++ b/LayoutTests/webarchive/loading/test-loading-archive-with-link.html
@@ -0,0 +1,23 @@
+<html><!-- webkit-test-runner [ AlwaysAllowLocalWebarchive=true ] -->
+<script>
+    if (window.testRunner) {
+        testRunner.dumpResourceLoadCallbacks();
+        testRunner.dumpAsText();
+        testRunner.waitUntilDone();
+    }
+    if (window.internals)
+        internals.setAlwaysAllowLocalWebarchive(true);
+    function timedout() {
+        if (window.testRunner)
+            testRunner.notifyDone();
+    }
+</script>
+<body>
+    <a href="resources/quarantined_top.webarchive" target="_blank">Click me</a>
+    <script>
+    document.querySelector("a").click()
+    setTimeout(timedout, 100);
+    </script>
+    This tests that clicking a link to a webarchive is cancelled.
+</body>
+</html>
Loading diff…

Original Bug Report

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