← All reports

[2] Validate connection access to DOMCache with DOMCacheIdentifier

HighWebKit NetworkProcess storageAuthBypass

Another site's cache will happily serve back what you wrote into it.

5d1be2c

High — the identical confused-deputy shape as the FileSystem fix, one storage type over. A renderer compromise limited to one site reaches every site's cached HTTP responses, and the write direction persists: a poisoned entry is served back as same-origin content on the victim's next visit.

The Cache Storage API (caches.open(), Cache.match/put/delete) keeps request/response pairs on behalf of each site, with the actual records held by the network process and every operation crossing IPC. A cache is named across that boundary by a DOMCacheIdentifier, minted when the cache is opened and quoted back on subsequent messages. The trust arrangement is the same as for OPFS: WebProcess-to-NetworkProcess IPC is untrusted input, and the network process is expected to confirm that the objects it resolves belong to the sender's site.

The angle: a compromised WebProcess can name another site's cache by identifier and read its stored HTTP responses, delete them, or write attacker-authored entries that the victim site later serves back to itself.

Many CacheStorage-related messages sent to NetworkStorageManager only carry a DOMCacheIdentifier when asking to operate on DOMCache storage, and NetworkStorageManager does not check whether the sender process actually has access to the requested cache. This lets a compromised process forge a DOMCacheIdentifier and access data from other origins. The fix stores the origin in CacheStorageManager and adds an origin accessor to CacheStorageCache, so NetworkStorageManager can run isSiteAllowedForConnection in the CacheStorage message handlers.

Source/WebKit/NetworkProcess/storage/NetworkStorageManager.cpp

-void NetworkStorageManager::cacheStorageRetrieveRecords(WebCore::DOMCacheIdentifier cacheIdentifier, WebCore::RetrieveRecordsOptions&& options, WebCore::DOMCacheEngine::CrossThreadRecordsCallback&& callback)
+void NetworkStorageManager::cacheStorageRetrieveRecords(IPC::Connection& connection, WebCore::DOMCacheIdentifier cacheIdentifier, WebCore::RetrieveRecordsOptions&& options, WebCore::DOMCacheEngine::CrossThreadRecordsCallback&& callback)
{
RefPtr cache = m_cacheStorageRegistry->cache(cacheIdentifier);
if (!cache)
return callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal));
 
+ auto origin = cache->origin();
+ MESSAGE_CHECK_COMPLETION(origin && isSiteAllowedForConnection(connection.uniqueID(), WebCore::RegistrableDomain { origin->topOrigin }), connection, callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal)));
+
cache->retrieveRecords(WTF::move(options), WTF::move(callback));
}
...
void NetworkStorageManager::cacheStoragePutRecords(IPC::Connection& connection, ...)
if (!cache)
return callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal));
 
+ auto origin = cache->origin();
+ MESSAGE_CHECK_COMPLETION(origin && isSiteAllowedForConnection(connection.uniqueID(), WebCore::RegistrableDomain { origin->topOrigin }), connection, callback(makeUnexpected(WebCore::DOMCacheEngine::Error::Internal)));
+
for (auto& record : records)
MESSAGE_CHECK_COMPLETION(record.responseBodySize >= CacheStorageDiskStore::computeRealBodySizeForStorage(record.responseBody), connection, ...);

Source/WebKit/NetworkProcess/storage/CacheStorageCache.cpp

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

CacheStorageManager now stores the owning origin unconditionally as a WebCore::ClientOrigin m_origin member and exposes origin() const. The previous std::optional<WebCore::ClientOrigin> constructor parameter — which existed only to decide whether to write the origin marker file — is replaced by a plain const WebCore::ClientOrigin& plus a new enum class ShouldWriteOriginFile : bool { No, Yes }. OriginStorageManager::StorageBucket::cacheStorageManager correspondingly passes the origin always and folds the m_level < UnifiedOriginStorageLevel::Standard test into the new ShouldWriteOriginFile argument, so the origin is retained even at Standard level where it was previously dropped.

CacheStorageCache::origin() const is added, forwarding through the WeakPtr<CacheStorageManager> m_manager and returning std::nullopt if the manager is gone.

Six message handlers — cacheStorageRemoveCache, cacheStorageReference, cacheStorageDereference, cacheStorageRetrieveRecords, cacheStorageRemoveRecords, cacheStoragePutRecords — gain a MESSAGE_CHECK/MESSAGE_CHECK_COMPLETION of origin && isSiteAllowedForConnection(connection.uniqueID(), RegistrableDomain { origin->topOrigin }) immediately after the m_cacheStorageRegistry->cache(cacheIdentifier) lookup. cacheStorageRemoveCache, cacheStorageRetrieveRecords and cacheStorageRemoveRecords had no IPC::Connection& parameter at all before this patch and had their signatures widened in NetworkStorageManager.h.

Treating an opaque identifier as a capability — an IPC handler resolves a caller-supplied ID through a globally-scoped registry without re-checking that the caller owns the resolved object.

Where this lives. NetworkStorageManager is the network-process-side entry point for storage IPC; the CacheStorage stack under it is CacheStorageRegistry, CacheStorageManager, CacheStorageCache and OriginStorageManager. It sits on the WebContent↔Network process trust boundary and is one of the enforcement points for per-site storage isolation.

Cache Storage API. caches.open(name) returns a Cache object whose records — request/response pairs — are stored by the network process on disk or in memory. Cache.match, Cache.put and Cache.delete are each a separate IPC round trip to NetworkStorageManager.

Origin keys. WebCore::ClientOrigin is a pair: topOrigin (the top-level document's origin, i.e. the storage partition) and clientOrigin (the origin of the frame or worker actually using the storage). Storage in modern WebKit is keyed by this pair, not a single origin. WebCore::RegistrableDomain is the eTLD+1 "site" derived from an origin; site-level granularity is what process-per-site isolation and the storage site-validation checks operate on.

DOMCacheIdentifier and the registry. DOMCacheIdentifier is an ObjectIdentifier minted in the network process when a cache is opened and handed back to the WebProcess; CacheStorageCache derives from Identified<WebCore::DOMCacheIdentifier>, so the identifier is the cache's identity for all subsequent messages. CacheStorageRegistry is a per-session table (HashMap<DOMCacheIdentifier, WeakPtr<CacheStorageCache>>) resolving identifier to live cache. It spans every origin in the session — origin partitioning lives one level up, in OriginStorageManager/CacheStorageManager, not in the registry.

isSiteAllowedForConnection. NetworkStorageManager's predicate for whether a given WebProcess connection may touch storage for a given site; it is the enforcement point for the storageSiteValidationEnabled mode configured on NetworkStorageManager.

MESSAGE_CHECK / MESSAGE_CHECK_COMPLETION. WebKit's IPC validation macros. On failure they treat the message as malformed and tear down the sending connection; the _COMPLETION variant additionally runs a supplied completion handler first, required for handlers that owe the sender a reply.

UnifiedOriginStorageLevel. A configuration knob controlling how much of a session's storage lives under the unified per-origin directory layout. At levels below Standard, per-feature directories still carry their own origin marker file describing which origin owns them.

Threat model. WebProcess-to-NetworkProcess IPC is untrusted input. A renderer that has been taken over can send any message with any field values, so every handler must validate that the objects it resolves belong to the sender.

The handlers that operate on an already-opened cache carried only the DOMCacheIdentifier in the message — cacheStorageRetrieveRecords, cacheStorageRemoveRecords and cacheStorageRemoveCache did not even receive the IPC::Connection& — and resolved the identifier straight through the registry into a CacheStorageCache with no check that the sending WebProcess was entitled to the origin that owns it. The origin-scoped handlers in the same file (cacheStorageOpenCache, cacheStorageAllCaches, lockCacheStorage, unlockCacheStorage) take a ClientOrigin from the message and route through originStorageManager(origin, ...), so they are subject to the existing site-validation path; the identifier-keyed handlers bypassed it entirely.

A second, enabling defect sat one layer down. OriginStorageManager::StorageBucket::cacheStorageManager passed the origin as a std::optional populated only when m_level < UnifiedOriginStorageLevel::Standard, because the sole consumer was StorageUtilities::writeOriginToFile. At Standard level the CacheStorageManager therefore had no idea which origin it belonged to, so no authorization check was even expressible from a DOMCacheIdentifier.

  Before:                                After:
  cacheStorageRetrieveRecords(id)        cacheStorageRetrieveRecords(conn, id)
    └─► registry->cache(id)                └─► registry->cache(id)
          └─► cache->retrieveRecords()          └─► cache->origin()
                (any origin's records)                └─► isSiteAllowedForConnection?
                                                            ├─ no  ─► MESSAGE_CHECK fails,
                                                            │          connection terminated
                                                            └─ yes ─► retrieveRecords()

The vulnerable handlers are IPC message receivers, not web-content-reachable primitives — ordinary JavaScript only ever obtains DOMCacheIdentifier values for its own caches through cacheStorageOpenCache, which is origin-checked. The realistic attacker is a WebProcess that already has arbitrary-IPC capability, which is exactly the model isSiteAllowedForConnection exists to defend.

Tracing the pre-fix code, the read direction runs in three steps. First, the attacker's process obtains a DOMCacheIdentifier naming a victim site's live CacheStorageCache — the identifier space is a single flat namespace in CacheStorageRegistry::m_caches, so enumeration across a modest integer range would suffice if identifiers are counter-derived, and no rate limiting or lockout exists on failed lookups (a miss simply returns Error::Internal via the if (!cache) early return, leaving the connection alive). Second, it sends CacheStorageRetrieveRecords with that identifier and a RetrieveRecordsOptions whose request.url() is null — CacheStorageCache::findRecords takes the url.isNull() branch and returns every record in the cache, not just a matching one. Third, cache->retrieveRecords(...) runs to completion and the callback ships the victim's CrossThreadRecord vector, responseBody included, back over the attacker's connection.

The write direction is the mirror image: CacheStoragePutRecords with the victim's identifier and attacker-authored CrossThreadRecord entries. The only remaining validation in that handler before the patch was the record.responseBodySize >= computeRealBodySizeForStorage(record.responseBody) size check — nothing tying the record's request URL or the cache to the sender's site. If the victim site later calls caches.match() on a poisoned key, the network process would return the attacker's Response under the victim's origin, which could yield script execution in the victim's context should the poisoned entry be a script or document the victim site loads from cache. CacheStorageRemoveCache and CacheStorageRemoveRecords give the destructive variant; CacheStorageReference/CacheStorageDereference let a foreign connection perturb the victim cache's m_cacheRefConnections bookkeeping and, plausibly, drive removeUnusedCache on a cache it does not own.

The vulnerable handlers execute in the NetworkProcess on NetworkStorageManager's work queue; the attacker sits in a WebContent process. This is not a sandbox escape — no code execution in the network process is implied. What it defeats is the isolation the multi-process split is supposed to buy. Post-fix, the same attempt terminates the offending connection through MESSAGE_CHECK.

This vulnerability weakens the WebContent↔NetworkProcess trust boundary and the per-site storage isolation model. The security model assumes a WebProcess connection can only reach storage belonging to sites it is permitted to host — the invariant isSiteAllowedForConnection exists to enforce; before the fix, every CacheStorage operation keyed only by DOMCacheIdentifier sidestepped it. An attacker able to emit arbitrary IPC to the network process could read another site's cached HTTP responses — which routinely include credentialed API responses and personal data — and could also write attacker-chosen Response objects into another site's cache, a persistent poisoning primitive that a subsequent legitimate visit would serve back as same-origin content. In effect this converts a single-site renderer compromise into cross-site data theft plus persistent cross-site content control, without any additional memory-safety bug.

The check could not previously exist because the origin was thrown away as a micro-optimization: it was materialized only when m_level < UnifiedOriginStorageLevel::Standard, since at that time the origin's only consumer was the marker file. That is a recurring shape in access-control bugs — authorization context is stored conditionally, keyed on whichever feature happened to need it first, and the later need for authorization finds the field empty. The fix's separation of "do I know my origin" (always) from "do I write an origin file" (ShouldWriteOriginFile) is the generalizable correction. Worth noting separately: the guard checks origin->topOrigin only, while Cache Storage is keyed by the full ClientOrigin pair. That is consistent with a process-per-site model where one process legitimately hosts a whole page including cross-site subframes, but it does mean the enforcement granularity is the partition, not the frame origin.