Remove blanket storage-root file path allow from blob access enforcement
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)
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
Source/WebKit/NetworkProcess/storage/IDBStorageConnectionToClient.cpp
Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp
LayoutTests/ipc/register-file-backed-blob-path-validation.html
Patch Details
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.
Background
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.
Analysis
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:
- Seed storage so the session's general storage directory exists on disk.
- Recover the root via the new
GeneralStoragePathForTestingmessage. - Create an ordinary 1-byte in-memory blob and take its blob URL.
- Send
RegisterInternalBlobURLOptionallyFileBackedre-pointing that URL at<storageRoot>/salt. - 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.
Insight
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.
Audit directions
-
Containment mistaken for authorization. Narrow: grep
Source/WebKit/NetworkProcessandSource/WebKit/GPUProcessfor validators that compare a caller-supplied path against a directory —FileSystem::parentPathwalks,startsWith,lexicallyNormalfollowed by a prefix comparison — rather than against a per-connection allowlist; then check the remaining callers ofSandboxExtension::createandBlobDataFileReferenceWithSandboxExtension::createfor paths that arrive without anm_allowedFilePathsmembership test. Match tell: aMESSAGE_CHECKwhose predicate is satisfied by any descendant of some root, where that root demonstrably holds data belonging to more than one origin. Wider: the same class arrives through non-path mechanisms — identifier-range checks that accept anyObjectIdentifierfrom a shared registry rather than one owned by this connection, origin checks comparing registrable domain instead of the full origin tuple, and sandbox-extension issuance keyed on a directory handle instead of a file handle; search WebKit IPC receivers forMESSAGE_CHECKpredicates whose right-hand side is container membership rather than a per-connection ownership lookup. Widest: the invariant — an authorization check must name the same granularity as the resource being accessed; containment in a per-container namespace is not a per-object grant — holds well outside WebKit, in S3 bucket-prefix IAM policies, Kubernetes namespace-scoped RBAC used to gate individual secrets, JWT audience/scope claims checked at service granularity while the handler acts on a specific record, and OAuth scopes naming a resource type rather than an instance. Portable tell: whenever the check's subject is a parent of the thing being accessed, ask who else has children under that same parent. -
Completeness of the replacement grant set. A tightened validator only holds if every legitimate producer of paths now issues a grant — and the inverse error, granting more than the specific object returned, silently recreates the original bug. Narrow: enumerate every producer of file paths the Network process hands to WebContent and confirm each calls
allowAccessToFileorallowAccessToBlobFilesForProcesswith exactly the returned path. This patch covereddidGetRecord,didGetAllRecords,didOpenCursor,didIterateCursor,generateIndexKeyForRecord, andNetworkStorageManager::getFile; check the remainingIDBStorageConnectionToClienthandlers (didPutOrAdd,didOpenDatabase) andregisterTemporaryBlobFilePathsfor result types that can carryblobFilePaths()but are absent fromresultBlobFilePaths()'s switch — thedefault: breakarm is where a missedIDBResultTypefalls through silently. Match tell: aswitchover a result or message enum inside an authorization helper with adefaultthat grants nothing, paired with a producer whose type is not enumerated. Wider: the shape recurs at any capability-issuing chokepoint where a coarse allow was replaced by fine-grained grants — sandbox extension issuance inGPUConnectionToWebProcess,WebPageProxy's file-upload extension handling, CoreIPC handlers that mintObjectIdentifiers; look for a type-dispatch deciding what to grant and check its arms against the dispatch deciding what to return. Widest: the invariant is the grant enumeration and the disclosure enumeration must be the same enumeration — two independently maintained pieces of code computing "what we return" and "what we authorize" will drift. This applies to GraphQL field resolvers versus field-level authorization directives, REST serializers versus policy objects, and capability-based OS APIs where the handle table and the permission table are maintained separately. Tell: two switches or two visitor implementations over the same type, living in different files. -
Grant on one thread, delivery on another. Narrow: audit
NetworkStorageManager::allowAccessToBlobFilesForProcessand the reworkedgetFile()for the ordering property they depend on — the reply insendResultWithBlobFileAccessis sent from inside the grant's completion handler, andgetFile()callswebConnection->allowAccessToFile(path)on the main run loop before hopping back toworkQueue()to complete — then check otherNetworkStorageManagermethods thatRunLoop::mainSingleton().dispatch(...)intoNetworkConnectionToWebProcessstate for the same discipline. Match tell: aRunLoop::mainSingleton().dispatchorworkQueue().dispatchwhose lambda performs a permission mutation, while the value that permission covers is sent to the peer outside that lambda. Wider: the shape appears wherever the permission store and the data channel have different serialization domains —WebProcessProxysandbox-extension handles minted on one queue and messaged on another, callers ofNetworkProcess::allowFilesAccessFromWebProcessgenerally, and anyCompletionHandlerchain crossing aWorkQueueboundary between the check and the use; grep forassertIsCurrent(in a function that also dispatches to a different run loop. Widest: the invariant is the grant must happen-before the disclosure, and "before" must be established by the same synchronization edge that carries the disclosure — carry it into any system with a permission cache separate from the request path: async ACL writes in a distributed store followed by an immediately-returned resource URL, capability tokens minted by one service and consumed by another under eventual consistency, or Rust/Go code where a permission map behind one mutex is updated in a spawned task while the handle returns to the caller. Tell: ask "if the grant callback ran arbitrarily late, could the peer still use the value?" — if yes, the ordering edge is missing.