Medium webkit Policy Bypass 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactOpening a maliciously crafted webarchive file may lead to universal cross-site scripting
DescriptionA logic issue was addressed with improved state management.
ComponentWebKit
Bug ClassPolicy Bypass
Tracker318271
Fix commitecb5a7bf7500 (webkit) +43/-8
CISA KEVNot listed
CreditedTomi Garcia (archyxsec)
Disclosed2026-09-14

Background

`.webarchive` files
Safari’s single-file web-page archive format. A loaded webarchive can declare its own origin/URLs, so loading one as a trusted local file effectively grants it script execution in whatever origin it claims — hence the quarantine gate.
`WebPageProxy::isQuarantinedAndNotUserApproved`
UIProcess routine (Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm) implementing that gate: it must (a) recognize a .webarchive by extension and (b) resolve the on-disk path to query quarantine.
`NSURL` string vs file URL parsing
[[NSURL alloc] initWithString:] parses a URL string, so a # in the name is treated as a fragment separator; initFileURLWithPath: treats the whole argument as a filesystem path with no fragment parsing. URL::fileSystemPath() yields the real on-disk path.

Root Cause Analysis

The old isQuarantinedAndNotUserApproved(const String& fileURLString) built the URL with [[NSURL alloc] initWithString:fileURLString...] and then inspected fileURL.get().pathExtension and fileURL.get().path. Because initWithString: performs URL parsing, a file whose name contains # (e.g. hash#x.webarchive) is split at the #: everything after it becomes the URL fragment. The parsed URL’s path ends at .../hash and its pathExtension is not webarchive. The extension comparison caseInsensitiveCompare:@"webarchive"] != NSOrderedSame therefore evaluates true, and the function returns false early — “this is not a webarchive, nothing to check.” Even had it proceeded, qtn_file_init_with_path(qf, fileURL.get().path...) would have been handed the truncated (wrong) path and failed to find the real file (ENOENT), again yielding false. Either way the quarantine gate is skipped and a quarantined, web-downloaded webarchive loads as if trusted — a universal cross-site scripting condition.

The patch changes the signature to take a const URL& and derives the real filesystem path with fileURL.fileSystemPath(), checks filePath.endsWithIgnoringASCIICase(".webarchive"_s) (a pure suffix test on the actual path, immune to fragment parsing), and reconstructs the NSURL with adoptNS([[NSURL alloc] initFileURLWithPath:filePath...]) so the quarantine query uses the correct on-disk path. Callers (loadFile, the data-store archive path) now pass a URL/requestURL instead of a raw string. The added test QuarantinedWebArchiveWithFragmentInName writes a quarantined archive named hash#x.webarchive and asserts the navigation does not load, pinning the exact bypass.

Key insight
The quarantine check parsed the file path as a URL string, so a # in the webarchive’s filename was treated as a URL fragment — truncating the extension and path so the .webarchive quarantine gate was skipped entirely.

Attack Path

  1. Craft the archive Attacker produces a malicious .webarchive that claims a victim origin and carries script, naming the file so it contains a # (e.g. hash#x.webarchive).
  2. Deliver via download User downloads/opens the file; macOS tags it with the web-download quarantine attribute.
  3. Load bypasses the gate Safari/WebKit calls isQuarantinedAndNotUserApproved with the file URL string; initWithString: parses #x.webarchive as a fragment, so the extension check says “not a webarchive” and returns false.
  4. Archive loads as trusted The quarantine refusal never fires; the webarchive is loaded.
  5. Universal XSS The archive executes script in the origin it declares, i.e. cross-site script execution against arbitrary origins (UXSS).

Impact Assessment

The vulnerable gate runs in the UIProcess (privileged browser process) and controls whether a quarantined local webarchive is loaded into a WebContent process. The achieved primitive is a security-policy bypass (quarantine/Gatekeeper-style approval) leading to universal cross-site scripting — script execution in an arbitrary web origin, per the advisory. It requires the user to open a downloaded webarchive whose filename the attacker chose to contain #; no memory corruption is involved. This is a correctness/parsing bypass, strong in consequence (UXSS) but gated on user-initiated opening of the file.

Changed Functions

FunctionChangeNotes
if
Source/WebKit/UIProcess/WebPageProxy.cpp
modified

Files Changed

  • Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
  • Source/WebKit/UIProcess/WebPageProxy.cpp
  • Source/WebKit/UIProcess/WebPageProxy.h
  • Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm

Audit Directions

  • `[[NSURL alloc] initWithString:]` on filesystem paths
    Audit every place a local file path is turned into an NSURL/URL via string parsing rather than initFileURLWithPath:/fileSystemPath(); #, ?, % in names all cause similar truncation/misparse.
  • Extension checks via `pathExtension`
    Replace pathExtension-based security decisions with suffix tests on the real filesystem path; pathExtension is affected by URL-vs-path parsing.
  • Other quarantine / local-file gates
    Review any UIProcess gate that classifies a file by name (downloads, Gatekeeper prompts, local resource loads) for the same string-vs-file-URL confusion.
diff --git a/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm b/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
index 7a452240d9b1..d4a991514bf2 100644
--- a/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
+++ b/Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm
@@ -1149,15 +1149,17 @@ NSDictionary *WebPageProxy::contentsOfUserInterfaceItem(NSString *userInterfaceI
 }
 
 #if PLATFORM(MAC)
-bool WebPageProxy::isQuarantinedAndNotUserApproved(const String& fileURLString)
+bool WebPageProxy::isQuarantinedAndNotUserApproved(const URL& fileURL)
 {
-    RetainPtr fileURL = adoptNS([[NSURL alloc] initWithString:fileURLString.createNSString().get()]);
-    if ([retainPtr(fileURL.get().pathExtension) caseInsensitiveCompare:@"webarchive"] != NSOrderedSame)
+    auto filePath = fileURL.fileSystemPath();
+    if (!filePath.endsWithIgnoringASCIICase(".webarchive"_s))
         return false;
 
+    RetainPtr nsFileURL = adoptNS([[NSURL alloc] initFileURLWithPath:filePath.createNSString().get()]);
+
     qtn_file_t qf = qtn_file_alloc();
 
-    int quarantineError = qtn_file_init_with_path(qf, fileURL.get().path.fileSystemRepresentation);
+    int quarantineError = qtn_file_init_with_path(qf, nsFileURL.get().path.fileSystemRepresentation);
 
     if (quarantineError == ENOENT || quarantineError == QTN_NOT_QUARANTINED)
         return false;
diff --git a/Source/WebKit/UIProcess/WebPageProxy.cpp b/Source/WebKit/UIProcess/WebPageProxy.cpp
index 3bcdcf148435..af3dbd299bf5 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.cpp
+++ b/Source/WebKit/UIProcess/WebPageProxy.cpp
@@ -2353,8 +2353,10 @@ RefPtr<API::Navigation> WebPageProxy::loadFile(const String& fileURLString, cons
     drainDeferredModalsForNewNavigation();
 #endif
 
+    URL fileURL { fileURLString };
+
 #if PLATFORM(MAC)
-    if (isQuarantinedAndNotUserApproved(fileURLString)) {
+    if (isQuarantinedAndNotUserApproved(fileURL)) {
         WEBPAGEPROXY_RELEASE_LOG(Loading, "loadFile: file cannot be opened because it is from an unidentified developer.");
         return nullptr;
     }
@@ -2363,7 +2365,6 @@ RefPtr<API::Navigation> WebPageProxy::loadFile(const String& fileURLString, cons
     if (!hasRunningProcess())
         launchProcess(Site(aboutBlankURL()), ProcessLaunchReason::InitialProcess);
 
-    URL fileURL { fileURLString };
     if (!fileURL.protocolIsFile()) {
         WEBPAGEPROXY_RELEASE_LOG(Loading, "loadFile: file is not local");
         return nullptr;
@@ -5522,7 +5523,7 @@ Expected<WebPageProxy::DataStoreUpdateResult, WebCore::ResourceError> WebPagePro
     }
 
 #if PLATFORM(MAC)
-    bool clientDoesNotHaveAccessToArchiveFile = !isSubstituteDataWebArchive && isQuarantinedAndNotUserApproved(requestURL.fileSystemPath());
+    bool clientDoesNotHaveAccessToArchiveFile = !isSubstituteDataWebArchive && isQuarantinedAndNotUserApproved(requestURL);
     if (clientDoesNotHaveAccessToArchiveFile) {
         auto error = WebKit::cancelledError(URL { requestURL });
         error.setType(WebCore::ResourceError::Type::Cancellation);
diff --git a/Source/WebKit/UIProcess/WebPageProxy.h b/Source/WebKit/UIProcess/WebPageProxy.h
index 220ec086b2b3..7224e75362b6 100644
--- a/Source/WebKit/UIProcess/WebPageProxy.h
+++ b/Source/WebKit/UIProcess/WebPageProxy.h
@@ -2662,7 +2662,7 @@ public:
     void requestImageBitmap(const WebCore::ElementContext&, CompletionHandler<void(std::optional<WebCore::ShareableBitmapHandle>&&, const String& sourceMIMEType)>&&);
 
 #if PLATFORM(MAC)
-    bool isQuarantinedAndNotUserApproved(const String&);
+    bool isQuarantinedAndNotUserApproved(const URL&);
 #endif
 
     void showNotification(IPC::Connection&, const WebCore::NotificationData&, RefPtr<WebCore::NotificationResources>&&);
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
index 2b896aafbb42..a5502204c751 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
@@ -769,4 +769,36 @@ TEST(LoadWebArchive, FailNavigationFromNonClientOrUserInitiatedWindowOpen)
     EXPECT_TRUE(failed);
 }
 
+TEST(LoadWebArchive, QuarantinedWebArchiveWithFragmentInName)
+{
+    RetainPtr archiveData = adoptNS([[NSData alloc] initWithContentsOfURL:[NSBundle.test_resourcesBundle URLForResource:@"helloworld" withExtension:@"webarchive"]]);
+    RetainPtr directory = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSUUID UUID].UUIDString];
+    [NSFileManager.defaultManager createDirectoryAtPath:directory.get() withIntermediateDirectories:YES attributes:nil error:nil];
+    RetainPtr fileURL = [NSURL fileURLWithPath:[directory.get() stringByAppendingPathComponent:@"hash#x.webarchive"]];
+    [archiveData.get() writeToURL:fileURL.get() atomically:YES];
+
+    [fileURL.get() setResourceValue:@{ (__bridge NSString *)kLSQuarantineTypeKey: (__bridge NSString *)kLSQuarantineTypeWebDownload } forKey:NSURLQuarantinePropertiesKey error:nil];
+
+    RetainPtr webView = adoptNS([[WKWebView alloc] init]);
+    RetainPtr delegate = adoptNS([[TestNavigationDelegate alloc] init]);
+    [webView setNavigationDelegate:delegate.get()];
+
+    __block bool finished = false;
+    __block bool loaded = false;
+    delegate.get().didFinishNavigation = ^(WKWebView *, WKNavigation *) {
+        loaded = true;
+        finished = true;
+    };
+    delegate.get().didFailProvisionalNavigation = ^(WKWebView *, WKNavigation *, NSError *) {
+        finished = true;
+    };
+
+    [webView loadRequest:[NSURLRequest requestWithURL:fileURL.get()]];
+    Util::run(&finished);
+
+    EXPECT_FALSE(loaded);
+
+    [[NSFileManager defaultManager] removeItemAtPath:directory.get() error:nil];
+}
+
 } // namespace TestWebKitAPI
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
index 2b896aafbb42..a5502204c751 100644
--- a/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
+++ b/Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
@@ -769,4 +769,36 @@ TEST(LoadWebArchive, FailNavigationFromNonClientOrUserInitiatedWindowOpen)
     EXPECT_TRUE(failed);
 }
 
+TEST(LoadWebArchive, QuarantinedWebArchiveWithFragmentInName)
+{
+    RetainPtr archiveData = adoptNS([[NSData alloc] initWithContentsOfURL:[NSBundle.test_resourcesBundle URLForResource:@"helloworld" withExtension:@"webarchive"]]);
+    RetainPtr directory = [NSTemporaryDirectory() stringByAppendingPathComponent:[NSUUID UUID].UUIDString];
+    [NSFileManager.defaultManager createDirectoryAtPath:directory.get() withIntermediateDirectories:YES attributes:nil error:nil];
+    RetainPtr fileURL = [NSURL fileURLWithPath:[directory.get() stringByAppendingPathComponent:@"hash#x.webarchive"]];
+    [archiveData.get() writeToURL:fileURL.get() atomically:YES];
+
+    [fileURL.get() setResourceValue:@{ (__bridge NSString *)kLSQuarantineTypeKey: (__bridge NSString *)kLSQuarantineTypeWebDownload } forKey:NSURLQuarantinePropertiesKey error:nil];
+
+    RetainPtr webView = adoptNS([[WKWebView alloc] init]);
+    RetainPtr delegate = adoptNS([[TestNavigationDelegate alloc] init]);
+    [webView setNavigationDelegate:delegate.get()];
+
+    __block bool finished = false;
+    __block bool loaded = false;
+    delegate.get().didFinishNavigation = ^(WKWebView *, WKNavigation *) {
+        loaded = true;
+        finished = true;
+    };
+    delegate.get().didFailProvisionalNavigation = ^(WKWebView *, WKNavigation *, NSError *) {
+        finished = true;
+    };
+
+    [webView loadRequest:[NSURLRequest requestWithURL:fileURL.get()]];
+    Util::run(&finished);
+
+    EXPECT_FALSE(loaded);
+
+    [[NSFileManager defaultManager] removeItemAtPath:directory.get() error:nil];
+}
+
 } // namespace TestWebKitAPI
Loading diff…