CVE-2026-86898
Overview
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.webarchiveby 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.
# 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
- Craft the archive
Attacker produces a malicious
.webarchivethat claims a victim origin and carries script, naming the file so it contains a#(e.g.hash#x.webarchive). - Deliver via download User downloads/opens the file; macOS tags it with the web-download quarantine attribute.
- Load bypasses the gate
Safari/WebKit calls
isQuarantinedAndNotUserApprovedwith the file URL string;initWithString:parses#x.webarchiveas a fragment, so the extension check says “not a webarchive” and returnsfalse. - Archive loads as trusted The quarantine refusal never fires; the webarchive is loaded.
- Universal XSS The archive executes script in the origin it declares, i.e. cross-site script execution against arbitrary origins (UXSS).
Impact Assessment
#; 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
| Function | Change | Notes |
|---|---|---|
ifSource/WebKit/UIProcess/WebPageProxy.cpp |
modified |
Files Changed
Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mmSource/WebKit/UIProcess/WebPageProxy.cppSource/WebKit/UIProcess/WebPageProxy.hTools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
Audit Directions
- `[[NSURL alloc] initWithString:]` on filesystem pathsAudit 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;pathExtensionis affected by URL-vs-path parsing. - Other quarantine / local-file gatesReview any UIProcess gate that classifies a file by name (downloads, Gatekeeper prompts, local resource loads) for the same string-vs-file-URL confusion.
Patch
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
Regression Test / PoC
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