[1] Validate connection access to FileSystem storage with FileSystemHandleIdentifier
Sixteen storage handlers took an integer as proof you owned the file.
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
Source/WebKit/NetworkProcess/storage/FileSystemStorageHandle.cpp
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/IPCTestingAPI.mm
Patch Details
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 FileSystemHandleIdentifier — closeHandle, 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.
Background
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.
Analysis
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.
Insight
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.
Audit directions
-
Authorization keyed to the presence of an identity field in the payload rather than to the operation being performed — so requests that name a resource by opaque handle silently skip a check that sibling requests receive. Narrow: audit the remaining
NetworkStorageManagermessage families inNetworkStorageManager.messages.inthat take a bare identifier with noClientOrigin—DisconnectFromStorageArea/SetItem/RemoveItem/Clear(WebKit::StorageAreaIdentifier),CacheStorageRemoveCache/CacheStorageReference/CacheStorageRetrieveRecords/CacheStoragePutRecords(WebCore::DOMCacheIdentifier), and the IDB handlers keyed onIDBDatabaseConnectionIdentifier/IDBResourceIdentifier— and check whether each resolves its identifier through a registry that spans all origins in the session. Match tell: a handler whose first act issomeRegistry->get*(identifier)followed directly by use, with noisSiteAllowedForConnection/canConnectionAccessSiteForWebStoragebetween them, while a neighbouring handler in the same file does have one. Wider: the same class appears anywhere a privileged process hands out integer handles and then trusts them back —RemoteRenderingBackend/RemoteGraphicsContextGLobject identifiers in the GPU process,WebSWServerConnectionservice-worker registration identifiers,RemoteMediaPlayerManagerProxyplayer identifiers; look for a sharedHashMap<SomeIdentifier, Ref<T>>reachable from more than one connection. Widest: the invariant is a handle is only a capability if it is unguessable AND scoped to the holder — otherwise every handler must re-derive the owner and authorize it. This applies to Chromium Mojo interfaces that pass raw IDs instead of per-interface message pipes, to POSIX file descriptors vs. path-based APIs, and to REST APIs with sequential object IDs (the classic IDOR/BOLA class). Match tell in any codebase: an identifier minted by the trusted side, sent to an untrusted side, and later accepted back as proof of entitlement without a server-side ownership lookup. -
An object that cannot answer "who owns me?" — the missing back-reference is what makes the authorization check unwritable, so the absence of a check is a symptom rather than the disease. Audit WebKit's network- and GPU-process resource classes for types registered in a shared registry that store no origin, session, or connection provenance. Narrow: within
Source/WebKit/NetworkProcess/storage, checkStorageAreaBasesubclasses andCacheStorageCachefor whether an origin orClientOriginis reachable from the object itself (asFileSystemStorageManager::m_originnow is) or only from the manager that created it. Wider: the same shape recurs wherever a factory knows the security context but the product does not — GPU-processRemoteResourceCacheentries,RemoteAudioDestinationManagerdestinations, ServiceWorkerSWServerWorkerrecords; the tell is a class whose constructor takes a path, buffer, or handle but not the principal it was created for. Widest: every security-relevant object should be able to name its own principal, because access-control code that must reach out to a separate table to find the owner will eventually be skipped. This applies to any multi-tenant server where request handlers look up rows by primary key, to Kubernetes-style controllers reconciling objects across namespaces, and to language runtimes attaching realm/compartment tags to objects. Match tell: an authorization function that takes both a subject and a resource but has to consult a third structure to relate them. -
Verify that the newly-added guard actually holds under the conditions the patch does not exercise.
canConnectionAccessFileSystemHandlereturns false whenhandle.origin()yieldsstd::nullopt, which happens when theWeakPtr<FileSystemStorageManager> m_managerhas been cleared — trace whether a handle can outlive its manager while still registered inFileSystemStorageHandleRegistry, since in that window every operation on it now fails closed (correct, but a behavior change worth confirming againstFileSystemStorageManager::~FileSystemStorageManager→close()). Separately, the check reducesClientOrigintoRegistrableDomain { origin->topOrigin }, discardingclientOriginand narrowing full origin to eTLD+1: examine whether two partitions that share a top-level registrable domain but differ inclientOriginor in scheme/port can still reach each other's handles, and whether the predicate is permissive when storage site validation is disabled. Match tell: anyMESSAGE_CHECKwhose predicate compares at coarser granularity than the key used to derive the storage directory — here the origin directory path hashes bothtopOriginandclientOrigin, while the check inspects onlytopOrigin's registrable domain. -
Repeated per-call-site enforcement, where security depends on a human remembering to paste a guard into every new handler. This patch adds the same
MESSAGE_CHECK_COMPLETION(canConnectionAccessFileSystemHandle(...))line sixteen times; the seventeenth handler added next year is the next bug. Investigate whether the IPC message-generation machinery inSource/WebKit/Scripts/webkit/messages.pyand the.messages.inattribute vocabulary (EnabledBy,DispatchedFrom,SharedPreferencesNeedsConnection) could express an ownership-validation attribute so authorization is generated rather than hand-written, and audit which existing receivers rely purely on hand-placedMESSAGE_CHECKfor origin scoping. Wider: the same fragility shows up in WebKit's sandbox extension handling and inNetworkConnectionToWebProcesshandlers that take resource identifiers. Widest: authorization implemented as a convention repeated at N call sites has an expected failure rate proportional to N; authorization implemented as a type, wrapper, or generated preamble has one. This applies to Rust newtype-wrapped authorized IDs, to typed capability tokens in Mojo, and to middleware-vs-per-endpoint auth in web frameworks. Match tell: grep any handler family where the same guard expression appears more than ~5 times verbatim, then diff the set of handlers that have it against the set that resolve the same identifier type — the difference is the bug list.