← All reports

[1] Validate connection access to FileSystem storage with FileSystemHandleIdentifier

HighWebKit NetworkProcess storageAuthBypass

Sixteen storage handlers took an integer as proof you owned the file.

07d83d0

High. Sixteen storage message handlers accepted an integer as proof of entitlement, so a renderer confined to one site could read, overwrite and delete every other site's private filesystem in the same session. No memory corruption needed — the escalation is already complete at the authorization layer.

The Origin Private File System gives each web origin a private, persistent filesystem reachable through navigator.storage.getDirectory(); in WebKit the real files live in the network process and web content holds only proxies. Directories and files are named across IPC by a FileSystemHandleIdentifier — an opaque integer minted by the network process when a handle is opened and quoted back on every subsequent operation. The network process is the trusted arbiter here: it is supposed to guarantee that a WebContent connection only ever touches storage for sites it is permitted to host.

The angle: a compromised WebContent process can replay a handle identifier minted for another origin and enumerate, read, overwrite or delete that origin's private filesystem, entirely inside the network process's own storage.

Many FileSystem-related messages sent to NetworkStorageManager only carry a FileSystemHandleIdentifier when asking to operate on FileSystem storage, and NetworkStorageManager does not check whether the sender process actually has access to the requested handle. This lets a compromised process forge a FileSystemHandleIdentifier and access data from other origins. The fix stores the origin in FileSystemStorageManager and adds an origin accessor to FileSystemStorageHandle, so NetworkStorageManager can run isSiteAllowedForConnection in the FileSystem message handlers. API test: IPCTestingAPI.FileSystemForgedHandleIdentifierRejected.

Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp

+bool NetworkStorageManager::canConnectionAccessFileSystemHandle(IPC::Connection::UniqueID connection, const FileSystemStorageHandle& handle) const
+{
+ auto origin = handle.origin();
+ return origin && isSiteAllowedForConnection(connection, WebCore::RegistrableDomain { origin->topOrigin });
+}
 
-void NetworkStorageManager::getHandleNames(WebCore::FileSystemHandleIdentifier identifier, CompletionHandler<void(Expected<Vector<String>, FileSystemStorageError>)>&& completionHandler)
+void NetworkStorageManager::getHandleNames(IPC::Connection& connection, WebCore::FileSystemHandleIdentifier identifier, CompletionHandler<void(Expected<Vector<String>, FileSystemStorageError>)>&& completionHandler)
{
RefPtr handle = m_fileSystemStorageHandleRegistry->getHandle(identifier);
if (!handle)
return completionHandler(makeUnexpected(FileSystemStorageError::Unknown));
 
+ MESSAGE_CHECK_COMPLETION(canConnectionAccessFileSystemHandle(connection.uniqueID(), *handle), connection, completionHandler(makeUnexpected(FileSystemStorageError::Unknown)));
+
completionHandler(handle->getHandleNames());
}
...
-void NetworkStorageManager::executeCommandForWritable(WebCore::FileSystemHandleIdentifier identifier, ...)
+void NetworkStorageManager::executeCommandForWritable(IPC::Connection& connection, WebCore::FileSystemHandleIdentifier identifier, ...)
{
RefPtr handle = m_fileSystemStorageHandleRegistry->getHandle(identifier);
if (!handle)
return completionHandler(FileSystemStorageError::Unknown);
 
+ MESSAGE_CHECK_COMPLETION(canConnectionAccessFileSystemHandle(connection.uniqueID(), *handle), connection, completionHandler(FileSystemStorageError::Unknown));
+
handle->executeCommandForWritable(streamIdentifier, type, position, size, dataBytes, hasDataError, WTF::move(completionHandler));
}

Source/WebKit/NetworkProcess/storage/FileSystemStorageHandle.cpp

+std::optional<WebCore::ClientOrigin> FileSystemStorageHandle::origin() const
+{
+ if (RefPtr manager = m_manager.get())
+ return manager->origin();
+ return std::nullopt;
+}

Tools/TestWebKitAPI/Tests/WebKit/WKWebView/IPCTestingAPI.mm

+static constexpr auto fileSystemGoodPageHTML = R"TESTRESOURCE(
+<script>
+var capturedIdentifier = null;
+IPC.addOutgoingMessageListener('Networking', function(msg) {
+ if (msg.name === IPC.messages.NetworkStorageManager_GetHandleNames.name && !capturedIdentifier) {
+ var buf = new DataView(msg.buffer);
+ capturedIdentifier = buf.getBigUint64(16, true);
+ }
+});
+
+var run = async() => {
+ var root = await navigator.storage.getDirectory();
+ await root.getFileHandle('test.txt', { create: true });
+ for await (var entry of root.entries()) { }
+ if (capturedIdentifier !== null)
+ alert('id:' + capturedIdentifier.toString());
...
+static constexpr auto fileSystemBadPageHTML = R"TESTRESOURCE(
+<script>
+var attack = (stolenId) => {
+ var net = IPC.connectionForProcessTarget('Networking');
+ var onReply = (reply) => {
+ var buf = new DataView(reply.buffer);
+ var hasValue = !!buf.getUint8(16);
+ if (hasValue)
+ alert('FAIL:access-granted');
+ else
+ alert('PASS:access-denied');
+ };
+ net.sendWithAsyncReply(0, IPC.messages.NetworkStorageManager_GetHandleNames.name, [ { type: 'uint64_t', value: BigInt(stolenId) } ], onReply);
+};
...
+ auto badPID = [badView _webProcessIdentifier];
+ EXPECT_NE(goodPID, badPID);
+
+ [badView evaluateJavaScript:[NSString stringWithFormat:@"attack('%@')", stolenIdentifier] completionHandler:nil];
+ EXPECT_WK_STREQ(@"PASS:access-denied", [badUIDelegate waitForAlert]);

The change threads the owning origin down into the FileSystem storage object graph, widens twelve handler signatures so they can see their sender, and inserts a single authorization predicate at sixteen call sites.

On the plumbing side, FileSystemStorageManager gains a WebCore::ClientOrigin m_origin member set from a new const WebCore::ClientOrigin& parameter on create and the constructor, exposed via origin(). OriginStorageManager::fileSystemStorageManager() and OriginStorageManager::StorageBucket::fileSystemStorageManager() gain a matching origin parameter, and all six fileSystemStorageManager(...) call sites in NetworkStorageManager.cpp (fileSystemGetDirectory, addGlobalIdentifierReference, removeGlobalIdentifierReferences, resolveGlobalIdentifier, registerFileSystemHandleRecordsForOrigin, and the putOrAdd IDB lambda) pass it through. FileSystemStorageHandle::origin() const is added, returning m_manager->origin() through the WeakPtr<FileSystemStorageManager> m_manager, or std::nullopt if the manager is gone.

On the enforcement side, NetworkStorageManager gains a private helper canConnectionAccessFileSystemHandle(IPC::Connection::UniqueID, const FileSystemStorageHandle&) const, which resolves the handle's origin and defers to the existing isSiteAllowedForConnection(connection, WebCore::RegistrableDomain { origin->topOrigin }). Twelve message handlers that previously took only a FileSystemHandleIdentifiercloseHandle, isSameEntry, move, removeEntry, resolve, createSyncAccessHandle, closeSyncAccessHandle, requestNewCapacityForSyncAccessHandle, createWritable, closeWritable, executeCommandForWritable, getHandleNames — get an IPC::Connection& first parameter in both .cpp and .h. Those twelve plus the four that already carried a connection but were unguarded (getFileHandle, getDirectoryHandle, getFile, getHandle) each receive a MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION on the new helper, placed immediately after the registry null check and before the handle is touched. closeHandle and closeSyncAccessHandle are restructured from if (RefPtr handle = ...) into early-return form so the check can be inserted.

The third cluster is the test: IPCTestingAPI.FileSystemForgedHandleIdentifierRejected captures a real FileSystemHandleIdentifier from one origin's outgoing IPC and replays it from a second, differently-originated WebContent process.

Authorization enforced only on requests that carry the resource owner's identity, while requests naming the same resource by an opaque global handle skip the check entirely.

Where this lives. NetworkStorageManager is an IPC::WorkQueueMessageReceiver in the network process, instantiated per PAL::SessionID, that services storage messages — FileSystem, Local/SessionStorage, IndexedDB, CacheStorage — sent from WebContent processes. It owns per-origin OriginStorageManager instances and is the enforcement point for storage-level origin isolation.

Origin Private File System. OPFS is a per-origin private filesystem exposed to web content through navigator.storage.getDirectory(), giving pages directory handles, file handles, createWritable() streams and createSyncAccessHandle() for byte-level I/O. The actual files live in the network process; WebContent holds only proxies that name them over IPC.

Identifiers and the registry. WebCore::FileSystemHandleIdentifier is an ObjectIdentifier-style opaque integer minted by the network process — FileSystemStorageHandle derives from Identified<WebCore::FileSystemHandleIdentifier> — and returned to WebContent to name a directory or file handle. FileSystemStorageHandleRegistry is a table owned by NetworkStorageManager mapping identifier to live handle; because the manager is per-session, getHandle(identifier) performs a flat lookup across every origin's and every connection's handles in that session.

ClientOrigin and RegistrableDomain. WebCore::ClientOrigin is a pair of origins: topOrigin (the top-level document's origin, used for storage partitioning) and clientOrigin (the origin of the frame or worker doing the access). Storage directories are derived by hashing both. WebCore::RegistrableDomain is the eTLD+1 of an origin; WebKit's site-isolation bookkeeping is keyed at site granularity rather than full origin.

isSiteAllowedForConnection. NetworkStorageManager's existing predicate answering "has the UI process told me this WebContent connection is permitted to act for this site?" It is active when storage site validation is enabled — the API test turns it on explicitly with _setStorageSiteValidationEnabled:YES.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit's IPC-validation macros. When the asserted condition is false they treat the message as evidence of a misbehaving sender — terminating or otherwise penalizing the connection — with the _COMPLETION variant first invoking a supplied completion handler so an async reply is not dropped.

IPC Testing API. A debug-only WebKit facility, enabled per-WKPreferences, that lets page JavaScript observe outgoing IPC (IPC.addOutgoingMessageListener) and send arbitrary hand-built messages to another process (IPC.connectionForProcessTarget(...).sendWithAsyncReply). It simulates what native code in a compromised WebContent process could do.

Normal flow. WebContent calls navigator.storage.getDirectory(), which sends FileSystemGetDirectory with a ClientOrigin; NetworkStorageManager validates the origin against the connection, obtains the origin's FileSystemStorageManager through OriginStorageManager, creates a root FileSystemStorageHandle, registers it, and returns its identifier. All subsequent operations reference that identifier.

Two authorization regimes existed side by side in the same file. Messages that carry a ClientOrigin in their payload — FileSystemGetDirectory, AddGlobalIdentifierReference, RemoveGlobalIdentifierReferences, ResolveGlobalIdentifier — were guarded by MESSAGE_CHECK(isSiteAllowedForConnection(connection.uniqueID(), RegistrableDomain { origin.topOrigin }), connection). The sixteen handlers that resolve their target through the registry had no equivalent check, and twelve of them did not even receive the IPC::Connection& needed to write one.

  WebContent A                Network process (per SessionID)         WebContent B
  ────────────                ───────────────────────────────         ────────────
  getDirectory(origin A) ──►  [origin check]  ──► handle #42
                              FileSystemStorageHandleRegistry
                                { 42 -> handle(origin A) }
                                        ▲
                                        │  flat lookup, no owner check
                                        └─────────────────────────  GetHandleNames(42)
                                                                     (no ClientOrigin
                                                                      in payload)

The enabling defect is that the handle object could not answer "who owns me?". FileSystemStorageHandle stored m_path, m_name, m_type and a WeakPtr<FileSystemStorageManager> m_manager; FileSystemStorageManager stored the on-disk path but not the ClientOrigin it was created for. Even a handler that wanted to authorize had nothing to authorize against.

This is not a memory-safety bug. getHandle() returns nullptr for unknown identifiers and every handler already null-checked, so a forged identifier resolves either to nothing or to a valid, correctly-typed handle belonging to another origin. The identifier is treated as an unforgeable capability by the network process, but it crosses the IPC boundary as a plain integer under the sender's control.

The API test is a working proof of concept and traces end to end. https://good.example/ loads in one WebContent process and runs await navigator.storage.getDirectory() followed by root.getFileHandle('test.txt', { create: true }); the network process mints an identifier for the root directory handle and registers it. The page then iterates root.entries(), emitting NetworkStorageManager_GetHandleNames; the installed outgoing-message listener reads the identifier out of the serialized buffer at the offset the test hardcodes (buf.getBigUint64(16, true)) and surfaces it via alert('id:' + ...). https://bad.example/ then loads in a different WebContent process — the test asserts EXPECT_NE(goodPID, badPID), so the two share nothing but the network process and its session-scoped registry — and calls IPC.connectionForProcessTarget('Networking').sendWithAsyncReply(0, NetworkStorageManager_GetHandleNames, [{ type: 'uint64_t', value: BigInt(stolenId) }], onReply).

Pre-fix, getHandleNames looked the integer up in the flat registry, found good.example's live root directory handle, and called handle->getHandleNames() unconditionally, so the reply would have carried another origin's directory listing — the test decodes success from buf.getUint8(16) and would report FAIL:access-granted, reading the reply's success flag at the offset its own decoder assumes. Post-fix, canConnectionAccessFileSystemHandle resolves the handle's ClientOrigin through FileSystemStorageManager::m_origin, isSiteAllowedForConnection returns false for bad.example's connection, and MESSAGE_CHECK_COMPLETION replies with FileSystemStorageError::Unknown (PASS:access-denied) while flagging the sender.

In a real attack the IPC Testing API stands in for native code in a compromised WebContent process. The capture step could be unnecessary if identifiers turn out to be enumerable, since GetHandleNames over a range of small integers would function as an oracle — but whether the identifier space is small or sequential enough for that depends on the generator's value distribution, which this change does not establish, so capture-and-replay is the only path the evidence demonstrates. Escalation beyond disclosure follows the same replay against the write-side handlers: CreateWritable plus ExecuteCommandForWritable would permit overwriting a victim origin's OPFS files, and RemoveEntry/Move would permit deletion or relocation — each of those handlers received an identical MESSAGE_CHECK_COMPLETION in this patch, which is the evidence that each was unguarded. Beyond read and write, CloseHandle/CloseSyncAccessHandle/CloseWritable give cross-origin denial of service against another origin's live handles, and RequestNewCapacityForSyncAccessHandle gives quota manipulation.

The attacker's position is a WebContent process; the vulnerable code runs in the network process on NetworkStorageManager's work queue. This is not a sandbox escape — no new code execution in the network process is gained. What it defeats is the network process's role as arbiter of cross-origin storage isolation, so a WebContent compromise scoped to one site would reach every site's OPFS data in the same session. The registry is per-SessionID, so cross-session data is out of reach through this path. Note also that the new check delegates to isSiteAllowedForConnection, so its effectiveness is tied to storage site validation being enabled — the API test sets _setStorageSiteValidationEnabled:YES explicitly, and behaviour with that mode off is a separate question this patch does not answer.

This vulnerability weakens the cross-origin storage isolation boundary that the network process is responsible for enforcing on behalf of all WebContent processes. The security model assumes that a FileSystemHandleIdentifier behaves as an unforgeable capability — that possession implies the sender was granted the handle for its own origin — and that origin isolation is enforced in the trusted network process rather than by the untrusted sender; before the fix, both assumptions held only for the subset of messages that redundantly carried a ClientOrigin. An attacker with code execution in (or a script-level IPC primitive over) a WebContent process could enumerate, read, overwrite, move and delete another origin's OPFS contents within the same session — a cross-origin data disclosure and tampering primitive that persists on disk, reachable without any additional network-process memory corruption.

The structurally interesting question is why the gap survived. NetworkStorageManager already had the right predicate and already applied it consistently — but only to messages whose payload happened to include a ClientOrigin. The check was bound to the presence of the origin in the wire format rather than to the security-sensitive operation, so exactly the messages where the origin was redundant got validated and the messages where it was load-bearing did not. The enabling condition was that the handle had no back-pointer to its origin at all; the substantive half of this patch is plumbing, and the sixteen MESSAGE_CHECK lines are mechanical once that plumbing exists. A registry keyed by a bare identifier and shared across every origin and connection in a session is a standing invitation to this class of bug — a registry partitioned per connection, or identifiers minted per connection, would have made the whole message family secure by construction instead of by sixteen repeated call-site checks.