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)
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
Source/WebKit/UIProcess/WebPageProxy.cpp
Source/WebKit/UIProcess/WebPageProxy.h
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/mac/LoadWebArchive.mm
Patch Details
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).
Background
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.
Analysis
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.
Insight
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.
Audit directions
-
Security decisions computed from a re-parsed identifier. Narrow: grep
Source/WebKit/UIProcess, especially the Cocoa subdirectories, forinitWithString:and ad-hocNSURL/URLconstruction whose argument originated fromfileSystemPath(),path(), or a client-suppliedString, and compare it against the representation actually handed to the filesystem or LaunchServices call —SandboxExtension::createHandlecall sites, download-destination validation, and custom URL-scheme handler file mapping are the first stops. Wider: the class shows up wherever validation and use travel through different conversion helpers — MIME/UTI sniffing keyed off a re-derived name, path allow-lists rebuilt from a URL string, security-scoped bookmark data round-tripped through a string. The tell on both rungs is one line deciding withparseA(identifier)while a nearby line acts onparseB(identifier), with nothing asserting the two agree. Widest: this is the general parser-differential "check one object, act on another" class, and it holds outside WebKit — Chromium'sGURLvsbase::FilePath, Node'surl.fileURLToPathvs raw string concatenation, the JVM'sjava.net.URIvsjava.io.File. The invariant to carry across codebases: the exact bytes you validated must be the bytes you hand to the OS. -
Fail-open error branches in boolean security predicates. Narrow: read every
return falsein thePLATFORM(MAC)helpers reachable fromWebPageProxy::loadFileandWebPageProxy::updateDataStoreForWebArchiveLoad— inisQuarantinedAndNotUserApproveda non-matching extension,ENOENT, andQTN_NOT_QUARANTINEDall return the permissive value — and ask whether "I could not evaluate this" deserves the same return value as "this is provably safe". Wider: the shape recurs in anybool-returning gate where a parse failure or an IO error collapses into the allow branch — content-filter decisions, app-bound-domain checks, browsing-warning lookups. The tell is an earlyreturn <allow>sitting on an error branch rather than on a proven-safe branch. Widest: an undecidable policy query must never be encoded as the permissive answer. That applies to everyunwrap_or(false), bareexcept: pass, and nullable-check-defaults-to-allow in any codebase; the mental test is "if this call had failed for an unrelated reason, would the user still be protected?" -
Attacker-influenced filename metacharacters. Narrow: exercise the webarchive gate and the download-destination path (
DownloadProxydestination handling,WebPageProxy::loadFile) with names containing#,?,%,;, an embedded newline, and a leading-, and confirm that the file the policy code stats is the file that is subsequently opened. Wider: any WebKit feature that keys behaviour off a name suffix or name segment — attachment handling viaAPIAttachment, drag-and-drop file promises,<input type=file>plumbing — derives meaning from an attacker-supplied name. The tell is an extension computed with a URL-aware accessor such aspathExtensionon a URL-parsed object, or any suffix test implemented as a search that stops at the first delimiter instead of a plain comparison on the filesystem path. Widest: the same filename-metacharacter class recurs in upload handlers, MIME dispatchers, and Windows alternate-data-stream naming; a name chosen by a remote party is data, never syntax, and any layer that re-interprets it as syntax is a boundary worth testing. -
Choke-point coverage for webarchive loads. Narrow: enumerate the UIProcess paths that can end in webarchive handling —
loadRequest, the file-request variants,loadDatawith the webarchive MIME type, session restore and back-forward navigation to afile:webarchive, and the webarchive-related SPI — and check which of them actually consultisQuarantinedAndNotUserApproved; the!isSubstituteDataWebArchiveconjunct inupdateDataStoreForWebArchiveLoadis itself worth confirming as an intended carve-out rather than an unreviewed exemption. Wider: the class is "policy enforced at N call sites instead of at one choke point", which also covers app-bound-domain enforcement and sandbox-extension issuance, where adding a new load entry point silently skips the check. The tell is a security predicate invoked from a handful of scattered sites rather than from the single function every path must cross. Widest: policy belongs at the narrowest choke point the data must traverse — the same audit applies to any system with multiple ingest APIs, from engines with several navigation entry points to server frameworks whose middleware can be sidestepped by an alternate route.