← All reports

isQuarantinedAndNotUserApproved() should use the parsed file URL instead of re-parsing the path

CVE: CVE-2026-86898 · Safari 27 · Released September 14, 2026 Impact: Opening a maliciously crafted webarchive file may lead to universal cross-site scripting Apple's description: A logic issue was addressed with improved state management. Credit: Tomi Garcia (archyxsec)

Severity: High | Component: WebKit UIProcess page-load path on macOS (WebPageProxy / WebPageProxyCocoa.mm) | ecb5a7b | Bugzilla 318271

High. A # in a filename was enough to make the quarantine gate stat a file that does not exist, and every error branch in that predicate means "allow". No memory corruption — the payoff is a webarchive rendering under an origin it picked for itself, gated only on getting the user to download and open it.

macOS stamps files that arrive from the network with a quarantine record, and WebKit's browser-side process consults that record before it will open a .webarchive — the one local file format that gets to declare which origins its contents belong to. The gate lives in WebPageProxy, on the UIProcess side of the load path, and answers a single boolean: is this archive quarantined and not yet approved by the user? For that answer to mean anything, the file the predicate inspects has to be the file the load subsequently opens.

The angle: A downloaded webarchive whose filename contains a # slips past the quarantine gate entirely and loads under an attacker-chosen origin.

Source/WebKit/UIProcess/Cocoa/WebPageProxyCocoa.mm

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

Source/WebKit/UIProcess/WebPageProxy.cpp

@@ WebPageProxy::loadFile
+ 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;
}
@@
if (!hasRunningProcess())
launchProcess(Site(aboutBlankURL()), ProcessLaunchReason::InitialProcess);
 
- URL fileURL { fileURLString };
if (!fileURL.protocolIsFile()) {
 
@@ WebPageProxy::updateDataStoreForWebArchiveLoad
- bool clientDoesNotHaveAccessToArchiveFile = !isSubstituteDataWebArchive && isQuarantinedAndNotUserApproved(requestURL.fileSystemPath());
+ bool clientDoesNotHaveAccessToArchiveFile = !isSubstituteDataWebArchive && isQuarantinedAndNotUserApproved(requestURL);

Source/WebKit/UIProcess/WebPageProxy.h

#if PLATFORM(MAC)
- bool isQuarantinedAndNotUserApproved(const String&);
+ bool isQuarantinedAndNotUserApproved(const URL&);
#endif

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm

+TEST(LoadWebArchive, QuarantinedWebArchiveWithFragmentInName)
+{
+ 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];
+
+ [webView loadRequest:[NSURLRequest requestWithURL:fileURL.get()]];
+ Util::run(&finished);
+
+ EXPECT_FALSE(loaded);
+}

The header change is the whole fix in one line: isQuarantinedAndNotUserApproved stops taking const String& and takes const URL&. Everything else follows from that signature.

In WebPageProxyCocoa.mm, the body no longer constructs its own NSURL. Where it previously called -[NSURL initWithString:] on the incoming string and read pathExtension off the result, it now asks the already-parsed URL for fileSystemPath() and tests the resulting path with filePath.endsWithIgnoringASCIICase(".webarchive"_s) — a plain suffix comparison on a string, with no URL grammar involved. Only after that test passes does it build an NSURL, and it builds it with -[NSURL initFileURLWithPath:], which treats its entire argument as a path rather than splitting it into components. That object's path.fileSystemRepresentation is what reaches qtn_file_init_with_path(). The quarantine error handling underneath — the ENOENT / QTN_NOT_QUARANTINED early return and the flag inspection below it — is untouched.

Both call sites in WebPageProxy.cpp are adjusted to hand over a URL. In loadFile, the URL fileURL { fileURLString } construction is hoisted above the PLATFORM(MAC) block so the same parsed object feeds the quarantine gate and the protocolIsFile() check further down; the relative order of protocolIsFile() and launchProcess is unchanged. In updateDataStoreForWebArchiveLoad, the argument goes from requestURL.fileSystemPath() to requestURL itself — deleting the conversion that was feeding a bare filesystem path into a function that parsed it as a URL.

The regression test writes a real webarchive to a file literally named hash#x.webarchive, stamps it with NSURLQuarantinePropertiesKey / kLSQuarantineTypeWebDownload to simulate a web download, loads it through -loadRequest:, and asserts EXPECT_FALSE(loaded).

UIProcess vs WebContent. WebKit splits a browser across processes: WebPageProxy lives in the browser (UI) process, which owns navigation policy decisions, while the page itself is parsed and executed in the sandboxed WebContent process. WebPageProxy::loadFile() and WebPageProxy::updateDataStoreForWebArchiveLoad() are UIProcess entry points on the load path, and the quarantine gate in question compiles only under PLATFORM(MAC).

webarchive. Apple's single-file page archive format bundles a main resource together with its subresources, and stores each one alongside the URL it was originally fetched from. When WebKit loads an archive it reconstructs the page from those recorded URLs — which is what separates a webarchive from an ordinary local HTML file.

macOS file quarantine. Files written by internet-facing applications carry a com.apple.quarantine extended attribute. The libquarantine C API — qtn_file_alloc(), qtn_file_init_with_path(), and the flag accessors — reads that record for a given on-disk path. QTN_NOT_QUARANTINED means the file has no record; ENOENT means there is no such file. A user-approval flag inside the record records that the user explicitly consented to open it. The LaunchServices keys NSURLQuarantinePropertiesKey and kLSQuarantineTypeWebDownload, used by the regression test, are how that record is read and written from Cocoa; kLSQuarantineTypeWebDownload marks a file as having arrived via a web download.

-[NSURL initWithString:] vs -[NSURL initFileURLWithPath:]. The former parses its argument under URL syntax rules, so reserved characters such as # and ? delimit the fragment and query components. The latter treats its whole argument as a filesystem path and performs no component splitting.

WTF::URL and URL::fileSystemPath(). URL is WebKit's parsed-URL type. fileSystemPath() converts an already-parsed file: URL into the corresponding on-disk path, applying percent-decoding along the way.

This is a parser differential: the predicate decided about one object and the loader opened another, because the two were derived by different parsers from the same string.

  Before:                                   After:
  "file:///tmp/d/hash#x.webarchive"         URL{ "file:///tmp/d/hash#x.webarchive" }
    └─► [NSURL initWithString:]               └─► fileSystemPath()
          path     = "/tmp/d/hash"                  = "/tmp/d/hash#x.webarchive"
          fragment = "x.webarchive"            └─► endsWithIgnoringASCIICase(".webarchive")
    └─► pathExtension == ""                          → match
          → != "webarchive" → return false     └─► initFileURLWithPath: (no splitting)
                  ALLOW ───► load proceeds     └─► qtn_file_init_with_path("/tmp/d/hash#x.webarchive")
                                                     → quarantined, unapproved → BLOCK

The left column is the pre-fix path. -[NSURL initWithString:] applies URL grammar to the string, so the # in hash#x.webarchive terminates the path component and everything after it becomes the fragment. pathExtension on that NSURL is empty, caseInsensitiveCompare:@"webarchive" fails, and the function takes its first return false — before it has touched the quarantine attribute at all. The extension test is not even the last line of defence here: had it somehow passed, qtn_file_init_with_path() would have been handed /tmp/d/hash, a path that does not exist on disk, yielding ENOENT and the same permissive return. Both roads lead to "allow".

That is the second half of the defect, and it is what converts a parsing discrepancy into a bypass rather than a false positive. Every failure branch in this predicate collapses into the same answer:

    if (!filePath.endsWithIgnoringASCIICase(".webarchive"_s))
        return false;            // wrong extension → allow
    ...
    if (quarantineError == ENOENT || quarantineError == QTN_NOT_QUARANTINED)
        return false;            // no such file, or no record → allow

"I could not evaluate this file" and "this file is provably safe" are encoded identically. A gate built that way turns any disagreement about which file is under discussion into a decision in the attacker's favour.

The two callers made that disagreement inevitable. loadFile passed the client-supplied file: URL string; updateDataStoreForWebArchiveLoad passed requestURL.fileSystemPath() — a bare, percent-decoded on-disk path with no scheme at all. One const String& parameter was accepting both and running URL parsing over each. Reaching the gate needs no compromised renderer: it sits in the UIProcess, so ordinary user-initiated file opening is the entire delivery mechanism, and the test case models exactly that with -loadRequest: on a file: URL.

What the bypass buys is not memory state. Per the bug title, the downstream primitive is a same-origin-policy bypass: a webarchive carries the origins its own main resource and subresources are attributed to, so an unapproved archive that loads anyway would obtain script execution under an origin of the attacker's choosing, with the cross-origin DOM access and credential theft that implies. The content still renders inside the sandboxed WebContent process — bypassing this gate does not itself cross a process boundary, and a separate bug would still be needed to escape the sandbox. The cost of entry is convincing a user to download and open one file.

After the fix, URL::fileSystemPath() yields the complete path including the #, the suffix test is a plain string comparison that no delimiter can truncate, and -[NSURL initFileURLWithPath:] performs no component splitting — so the extension test and the quarantine lookup both address the file the caller actually resolved.

A # in a filename made the quarantine gate stat a nonexistent path, and the predicate's "file not found" branch returns the same value as "not quarantined": allow.

The load-bearing defect is the parameter type. isQuarantinedAndNotUserApproved(const String&) accepted a file: URL string from one caller and a bare filesystem path from the other, then silently applied URL parsing to both; the one-line change from requestURL.fileSystemPath() to requestURL is the entire bug in miniature. WebKit's long-running migration from String-typed URLs and paths to WTF::URL exists precisely to make that mix-up unrepresentable in the type system — which makes every surviving const String& parameter that means "a URL, or maybe a path" inside a security decision a candidate for the same defect.