← All reports

Remove blanket storage-root file path allow from blob access enforcement

HighNetwork process — blob registration IPC and the IndexedDB storage stackAuthBypass

CVE: CVE-2026-43732 · Safari 26.5.2 · Released June 29, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: A path handling issue was addressed with improved validation. Credit: Nan Wang (@eternalsakura13)

5be1236 | Bugzilla 313085

High. The gate that decides which files a renderer may turn into a readable blob accepted "lives under the storage root" as a substitute for "was handed to you" — and that root holds every origin's persisted data for the session. No memory corruption, but a renderer foothold converts cleanly into cross-origin storage reads.

The Network process is the only party in WebKit that touches persisted website data on disk; the sandboxed WebContent process never opens those files itself and must name a path over IPC and ask for it back. That asymmetry only holds up if the Network process can answer one question correctly for every path it is handed: did I give you this? NetworkConnectionToWebProcess::isFilePathAllowed() is where that question gets answered for file-backed blob registration, and before this commit it was answering a different, much weaker one.

The angle: A renderer that can forge IPC could turn any file under the session's storage root — including other origins' IndexedDB blob payloads and Origin Private File System contents — into a blob it can read.

Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.cpp

-bool NetworkConnectionToWebProcess::isFilePathAllowed(NetworkSession& session, String path)
+bool NetworkConnectionToWebProcess::isFilePathAllowed(String path)
{
path = FileSystem::lexicallyNormal(path);
auto parentPath = FileSystem::parentPath(path);
while (parentPath != path) {
- if (m_allowedFilePaths.contains(path) || parentPath == session.storageManager().path() || parentPath == session.storageManager().customIDBStoragePath())
+ if (m_allowedFilePaths.contains(path))
return true;
path = parentPath;
parentPath = FileSystem::parentPath(path);
}
if (blobFileAccessEnforcementEnabled() && shouldCheckBlobFileAccess())
- MESSAGE_CHECK(isFilePathAllowed(*session, fileBackedPath));
+ MESSAGE_CHECK(isFilePathAllowed(fileBackedPath));

Source/WebKit/NetworkProcess/storage/IDBStorageConnectionToClient.cpp

+static Vector<String> resultBlobFilePaths(const WebCore::IDBResultData& resultData)
+{
+ Vector<String> paths;
+ switch (resultData.type()) {
+ case WebCore::IDBResultType::GetRecordSuccess:
+ case WebCore::IDBResultType::OpenCursorSuccess:
+ case WebCore::IDBResultType::IterateCursorSuccess:
+ paths.appendVector(resultData.getResult().value().blobFilePaths());
+ for (auto& record : resultData.getResult().prefetchedRecords())
+ paths.appendVector(record.value.blobFilePaths());
+ break;
+ case WebCore::IDBResultType::GetAllRecordsSuccess:
+ for (auto& value : resultData.getAllResult().values())
+ paths.appendVector(value.blobFilePaths());
+ break;
+ default:
+ break;
+ }
+ return paths;
+}
+
+template<typename Message>
+void IDBStorageConnectionToClient::sendResultWithBlobFileAccess(WebIDBResult&& result)
+{
+ auto blobFilePaths = resultBlobFilePaths(result.resultData());
+ RefPtr networkStorageManager = m_networkStorageManager.get();
+ if (!networkStorageManager) {
+ IPC::Connection::send(m_connection, Message(WTF::move(result)), 0);
+ return;
+ }
+ networkStorageManager->allowAccessToBlobFilesForProcess(m_identifier, WTF::move(blobFilePaths), [connection = m_connection, result = WTF::move(result)]() mutable {
+ IPC::Connection::send(connection, Message(WTF::move(result)), 0);
+ });
+}
 
void IDBStorageConnectionToClient::didGetRecord(const WebCore::IDBResultData& resultData)
{
- IPC::Connection::send(m_connection, Messages::WebIDBConnectionToServer::DidGetRecord(prepareGetResult(resultData)), 0);
+ sendResultWithBlobFileAccess<Messages::WebIDBConnectionToServer::DidGetRecord>(prepareGetResult(resultData));
}

Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp

+void NetworkStorageManager::allowAccessToBlobFilesForProcess(WebCore::ProcessIdentifier processIdentifier, Vector<String>&& filePaths, CompletionHandler<void()>&& completionHandler)
+{
+ assertIsCurrent(workQueue());
+
+ if (filePaths.isEmpty())
+ return completionHandler();
+
+ RunLoop::mainSingleton().dispatch([protectedThis = Ref { *this }, processIdentifier, filePaths = crossThreadCopy(WTF::move(filePaths)), completionHandler = WTF::move(completionHandler)] mutable {
+ RefPtr process = protectedThis->m_process.get();
+ if (!process)
+ return protectedThis->workQueue().dispatch(WTF::move(completionHandler));
+ process->allowFilesAccessFromWebProcess(processIdentifier, filePaths, [protectedThis = WTF::move(protectedThis), completionHandler = WTF::move(completionHandler)] mutable {
+ protectedThis->workQueue().dispatch(WTF::move(completionHandler));
+ });
+ });
+}
- completionHandler(handle->path());
+ RunLoop::mainSingleton().dispatch([protectedThis = Ref { *this }, connection = Ref { connection }, path = crossThreadCopy(handle->path()), completionHandler = WTF::move(completionHandler)] mutable {
+ if (RefPtr process = protectedThis->m_process.get()) {
+ if (RefPtr webConnection = process->webProcessConnection(connection.get()))
+ webConnection->allowAccessToFile(path);
+ }
+ protectedThis->workQueue().dispatch([path = crossThreadCopy(WTF::move(path)), completionHandler = WTF::move(completionHandler)] mutable {
+ completionHandler(WTF::move(path));
+ });
+ });

LayoutTests/ipc/register-file-backed-blob-path-validation.html

+ localStorage.setItem('seed', '1');
+ let storagePath = await generalStoragePath();
+ let targetPath = storagePath + '/salt';
+ let blobURL = URL.createObjectURL(new Blob([new Uint8Array([0])]));
+ CoreIPC.Networking.NetworkConnectionToWebProcess.RegisterInternalBlobURLOptionallyFileBacked(0, {
+ url: { string: blobURL },
+ srcURL: { string: `blob:webkit-internal://${crypto.randomUUID()}` },
+ fileBackedPath: targetPath,
+ contentType: 'application/octet-stream',
+ });
+ let size = await blobSize(blobURL);
+ log('Unauthorized file-backed blob registration rejected: ' + (size === 1 ? 'PASS' : 'FAIL (BlobSize=' + size + ')'));

The eleven touched files split into three groups: the validator itself, the grant-issuing machinery that had to be built before the validator could be tightened, and the regression test plus the test-only IPC it needs.

The validator. isFilePathAllowed() loses its NetworkSession& parameter entirely — it no longer needs the session because it no longer consults the session's storage paths. The upward walk over parent directories remains, but the loop body collapses from a three-way disjunction to a single m_allowedFilePaths.contains(path) test. The three call sites in registerInternalFileBlobURL() (which checks both path and, on non-Cocoa builds, replacementPath) and registerInternalBlobURLOptionallyFileBacked() are updated to the one-argument signature. Nothing else about the MESSAGE_CHECK wrapping changes: a rejected path still terminates the sending connection.

The grants. Deleting a blanket allow only works if every legitimate path is separately granted, and the bulk of the diff is that retrofit. IDBStorageConnectionToClient gains a free function resultBlobFilePaths() that pulls blobFilePaths() out of the four IDBResultType variants that can carry them — GetRecordSuccess, OpenCursorSuccess, IterateCursorSuccess (including each of prefetchedRecords()), and GetAllRecordsSuccess — and a template<typename Message> sendResultWithBlobFileAccess(WebIDBResult&&) that issues the grant and sends Message from the grant's completion handler. didGetRecord, didGetAllRecords, didOpenCursor and didIterateCursor are each rewritten from a direct IPC::Connection::send into a one-line call through that template. generateIndexKeyForRecord gets the same treatment by hand, capturing its six arguments into a sendResult lambda and granting value.blobFilePaths() first.

NetworkStorageManager::allowAccessToBlobFilesForProcess() is the new grant chokepoint: it asserts it is on the storage work queue, short-circuits when the path list is empty, hops to the main run loop to call NetworkProcess::allowFilesAccessFromWebProcess(), then hops back to the work queue to run the completion handler. Reaching it from the IDB side required an ownership edge that did not exist — IDBStorageRegistry gains a ThreadSafeWeakRef<NetworkStorageManager> m_manager and an explicit constructor taking the manager, and NetworkStorageManager's own constructor now passes *this at lazyInitialize time.

The File System Access path gets the same discipline: NetworkStorageManager::getFile() now takes the IPC::Connection& so it can find the requesting NetworkConnectionToWebProcess, and instead of completing with handle->path() inline it dispatches to the main thread, calls webConnection->allowAccessToFile(path), and only then hops back to the work queue to complete.

The test. A new [EnabledBy=AllowTestOnlyIPC] GeneralStoragePathForTesting() -> (String path) message and its handler let the layout test discover the storage root at runtime. register-file-backed-blob-path-validation.html then seeds storage, asks for the root, and tries to register a file-backed blob over <storageRoot>/salt — a file that belongs to the storage layer, not to the test's origin. The pass condition is that the registration is rejected and BlobSize reports the 1-byte in-memory blob the test created rather than the on-disk salt file's length.

The process split. WebKit runs untrusted web content in a sandboxed WebContent process and does networking plus on-disk website data storage in a separate Network process. WebContent cannot open the storage directory itself; anything it wants from disk it must request over IPC.

File-backed blobs. A Blob need not be backed by an in-memory buffer — it can be a reference to a file on disk. WebContent registers one by sending the Network process a blob URL together with a filesystem path (RegisterInternalFileBlobURL, RegisterInternalBlobURLOptionallyFileBacked); the Network process wraps that path in a BlobDataFileReferenceWithSandboxExtension and serves subsequent reads of the blob from the file.

MESSAGE_CHECK. WebKit's IPC validation macro. If the asserted condition is false the message is treated as invalid and the sending connection is torn down. It is the standard way a privileged process validates arguments it received from a less-privileged one.

The per-connection allowlist. NetworkConnectionToWebProcess keeps m_allowedFilePaths, a set of lexically-normalized paths that this specific WebContent connection has been explicitly granted. allowAccessToFile() adds to it. This is the per-file capability store.

The general storage directory. NetworkStorageManager::path() is the per-session root under which all persisted website data for the session lives, with per-origin subdirectories beneath it and storage-layer files such as salt alongside them. customIDBStoragePath() is an alternative root used when a custom IndexedDB path is configured. Both are shared containers — every origin in the session has data underneath them.

IndexedDB blob storage. IndexedDB values containing Blob or File objects are persisted as separate files on disk rather than inline in the record. When a record is read back, the resulting IDBResultData carries blobFilePaths() naming those files, and the Network process passes them to WebContent so the blob can be reconstructed on the other side. prefetchedRecords() are extra cursor records the server ships ahead of iteration, each carrying its own value and therefore its own blob paths. IDBStorageConnectionToClient is the Network-process object that translates IndexedDB server callbacks (didGetRecord, didOpenCursor, …) into IPC messages to WebContent's WebIDBConnectionToServer.

Threading. NetworkStorageManager does its storage work on a dedicated work queue — hence assertIsCurrent(workQueue()) — while NetworkProcess and NetworkConnectionToWebProcess state, m_allowedFilePaths included, is main-thread-owned. Any code that wants to mutate a connection's allowlist from storage code has to cross that boundary and come back.

IPC testing API. With IPCTestingAPIEnabled, layout tests get a window.IPC / CoreIPC object that can send raw IPC messages from page script. This is how a regression test can act like a compromised renderer without needing an actual renderer compromise.

The root cause is a granularity mismatch in an authorization predicate: the check named a container where the resource was a specific object.

  isFilePathAllowed("<storageRoot>/<otherOrigin>/idb/blob-42")

  Before:                              After:
  walk up parents ──┐                  walk up parents ──┐
    m_allowedFilePaths.contains? no    m_allowedFilePaths.contains? no
    parent == storageRoot?  ── YES ──► allow       (disjunct deleted)
    parent == customIDBPath?           ...
                                       reach fs root ──► MESSAGE_CHECK fails

The loop walked path upward toward the filesystem root, and on each step asked three questions instead of one. Two of them — parentPath == session.storageManager().path() and parentPath == session.storageManager().customIDBStoragePath() — tested containment, not identity. Because the walk continues all the way up, "is any ancestor of this path the storage root?" is exactly "does this path live anywhere under the storage root?", and the storage root is the per-session container for every origin's persisted data. The predicate that was supposed to mean the Network process handed you this file had degenerated into this file is inside the shared box.

The mechanism from there is short. A WebContent process that can send RegisterInternalBlobURLOptionallyFileBacked with a chosen fileBackedPath names a path beneath the storage root — another origin's IndexedDB blob payload, an Origin Private File System file, the storage layer's own salt — and the MESSAGE_CHECK passes. The Network process constructs a BlobDataFileReferenceWithSandboxExtension over that path, and every subsequent read of the blob URL returns the file's contents to the process that asked. The bundled layout test walks precisely this route:

  1. Seed storage so the session's general storage directory exists on disk.
  2. Recover the root via the new GeneralStoragePathForTesting message.
  3. Create an ordinary 1-byte in-memory blob and take its blob URL.
  4. Send RegisterInternalBlobURLOptionallyFileBacked re-pointing that URL at <storageRoot>/salt.
  5. Query BlobSize — if the size is no longer 1, the re-pointing took and the on-disk file is readable.

Note what this primitive is and is not. There is no write side; the attacker-supplied path flows only into BlobDataFileReferenceWithSandboxExtension::create() for reading. It is not a sandbox escape either — no code executes in the Network process and nothing outside the storage tree becomes reachable. Its value is as a post-compromise step: a renderer foothold, obtained by whatever means, becomes cross-origin read access to persisted user data, which is exactly the shape of "processing maliciously crafted web content may disclose sensitive user information". Ordinary JavaScript cannot reach it, because script has no control over fileBackedPath — the prerequisite is IPC-forging ability in WebContent, or an enabled IPC testing surface.

Which raises the question of why the two directory disjuncts were there at all. The correct mechanism already existed and sat in the same predicate: m_allowedFilePaths, populated one path at a time by allowAccessToFile(). The disjuncts were standing in for grants nobody had written. IndexedDB genuinely does return blob file paths to WebContent — that is how a File survives a round trip through a database — and those paths live under the storage root, so a coarse "anything under the root" allow made the feature work without anyone having to wire a grant into the IDB result path. Deleting the disjuncts is one line; the rest of the patch is paying the deferred bill. resultBlobFilePaths() enumerates which result types carry paths, allowAccessToBlobFilesForProcess() gets those paths into the connection's allowlist across the work-queue/main-thread boundary, and getFile() does the same for File System Access. Only after that can the strict check survive contact with real usage.

The ordering inside sendResultWithBlobFileAccess is the part worth reading twice. The grant is asynchronous — work queue to main run loop, allowFilesAccessFromWebProcess(), back to the work queue — while the reply carrying the paths is a plain IPC::Connection::send. Sending the reply eagerly and letting the grant land whenever it lands would compile, pass tests, and be wrong: WebContent would briefly hold paths it is not yet authorized for and could race its own blob registration against the grant's arrival. Instead the send lives inside the completion handler, so the same continuation that observes the grant completing is the one that hands over the value. getFile() takes the equivalent shape from the other direction — allowAccessToFile(path) runs on the main thread before the hop back to the work queue that completes with the path.

An access-control check that asks "is this path under the storage root?" is asking about the container, not the file, and the storage root holds every origin's data.

This is the usual life cycle of a coarse allow: it is not a mistaken belief that directory containment implies authorization, it is a placeholder for a per-object grant that was never implemented, and it survives until someone asks what else lives under that directory. The tell is a validator that already contains the correct fine-grained mechanism alongside the coarse one — here, m_allowedFilePaths.contains(path) sitting in the same if as the two directory comparisons. When you find that pairing, the coarse disjunct is usually load-bearing for exactly one feature nobody wanted to plumb grants through, and the real work of fixing it is finding every producer of legitimate paths, not deleting the check.