← All reports

Remove ResourceRequest.m_cachePartition

CVE: CVE-2026-64753 · Safari 27 · Released September 14, 2026 Impact: Processing maliciously crafted web content may disclose sensitive user information Apple's description: A permissions issue was addressed by removing the vulnerable code. Credit: Viggo Lekdorf

Severity: Medium | Component: WebCore network request layer / cache partitioning | 71730be | Bugzilla 315121

Medium, and the ceiling is disclosure rather than corruption. Two fields that the loader requires to agree — the first party a load is attributed to, and the cache namespace it lands in — were separately settable, and four call sites each derived the second from a different source. Divergence means a lookup crosses a site boundary.

Web caches are shared state indexed by URL, which makes them a natural side channel between unrelated sites; every modern engine responds by widening the cache key to include the top-level site, so https://cdn.example/lib.js fetched under attacker.test occupies a different slot than the same URL fetched under bank.test. In WebKit that widening is a string — a registrable domain — carried on the request object itself, and both the in-process MemoryCache and the Network process's on-disk NetworkCache key on it. The invariant that makes the scheme sound is narrow and unstated in code: that string must always be the registrable domain of firstPartyForCookies, the field the request already carries to attribute cookies and same-site decisions.

The angle: Before the fix, a request could name one site as its first party while its cache lookups landed in a different site's partition — reading, or seeding, entries belonging to a top-level site the page never visited.

Source/WebCore/platform/network/ResourceRequestBase.cpp

-void ResourceRequestBase::setCachePartition(const String& cachePartition)
+String ResourceRequestBase::cachePartition() const
{
#if ENABLE(CACHE_PARTITIONING)
- ASSERT(!cachePartition.isNull());
- ASSERT(cachePartition == partitionName(cachePartition));
- m_cachePartition = cachePartition;
-#else
- UNUSED_PARAM(cachePartition);
-#endif
-}
-
-String ResourceRequestBase::partitionName(const String& domain)
-{
- if (domain.isNull())
+ if (!m_shouldBlockThirdPartyStorage)
return emptyString();
- auto highLevel = PublicSuffixStore::singleton().topPrivatelyControlledDomain(domain);
- if (highLevel.isNull())
+ RegistrableDomain domain(firstPartyForCookies());
+ if (domain.isEmpty())
return emptyString();
- return highLevel;
+ return domain.string();
+#else
+ return emptyString();
+#endif
}
 
void ResourceRequestBase::setAsIsolatedCopy(const ResourceRequest& other)
{
...
- setCachePartition(other.cachePartition().isolatedCopy());
+ setShouldBlockThirdPartyStorage(other.shouldBlockThirdPartyStorage());

Source/WebCore/platform/network/ResourceRequestBase.h

- WEBCORE_EXPORT static String partitionName(const String& domain);
- const String& cachePartition() const LIFETIME_BOUND { return m_cachePartition; }
- WEBCORE_EXPORT void setCachePartition(const String&);
- void setDomainForCachePartition(const String& domain) { setCachePartition(partitionName(domain)); }
+ WEBCORE_EXPORT String cachePartition() const;
+ bool shouldBlockThirdPartyStorage() const { return m_shouldBlockThirdPartyStorage; }
+ void setShouldBlockThirdPartyStorage(bool value) { m_shouldBlockThirdPartyStorage = value; }
...
RequestData m_requestData;
String m_initiatorIdentifier;
- String m_cachePartition { emptyString() };
RefPtr<FormData> m_httpBody;
...
+ bool m_shouldBlockThirdPartyStorage : 1 { true };
bool m_hiddenFromInspector : 1;

Source/WebCore/dom/ScriptExecutionContext.cpp

String ScriptExecutionContext::domainForCachePartition() const
{
- if (!m_domainForCachePartition.isNull())
- return m_domainForCachePartition;
-
if (m_storageBlockingPolicy != StorageBlockingPolicy::BlockThirdParty)
return emptyString();
 
return protect(topOrigin())->domainForCachePartition();
}
 
+bool ScriptExecutionContext::shouldBlockThirdPartyStorage() const
+{
+ return m_storageBlockingPolicy == StorageBlockingPolicy::BlockThirdParty;
+}

Source/WebCore/page/Page.cpp

- if (document->settings().storageBlockingPolicy() != StorageBlockingPolicy::BlockThirdParty)
- document->setDomainForCachePartition(String { emptyString() });
- else
- document->setDomainForCachePartition(origin->domainForCachePartition());
+ document->setStorageBlockingPolicy(document->settings().storageBlockingPolicy());

Source/WebCore/platform/network/cocoa/ResourceRequestCocoa.mm

if (m_nsRequest) {
- RetainPtr<NSString> cachePartition = [NSURLProtocol propertyForKey:bridge_cast(_kCFURLCachePartitionKey) inRequest:m_nsRequest.get()];
- if (cachePartition)
- m_cachePartition = cachePartition.get();
+ RetainPtr cachePartition = dynamic_objc_cast<NSString>([NSURLProtocol propertyForKey:bridge_cast(_kCFURLCachePartitionKey) inRequest:m_nsRequest.get()]);
+ m_shouldBlockThirdPartyStorage = !![cachePartition length];
}

Source/WebKit/Shared/WebCoreArgumentCodersPlatform.serialization.in

[CreateUsing=fromResourceRequestData] class WebCore::ResourceRequest {
Variant<WebCore::ResourceRequest::RequestData, WebCore::ResourceRequestPlatformData> getRequestDataToSerialize();
- String cachePartition();
+ bool shouldBlockThirdPartyStorage();
bool hiddenFromInspector();
};

The core of the change is a state-shape reduction: ResourceRequestBase loses its String m_cachePartition { emptyString() } member and gains a single bit, bool m_shouldBlockThirdPartyStorage : 1 { true }. cachePartition() stops being a stored-field accessor returning const String& and becomes a computed getter — clear bit returns emptyString(), otherwise it constructs RegistrableDomain domain(firstPartyForCookies()) and returns domain.string(). Because the value is now materialized per call, CachedResource::cachePartition() changes return type from const String& to String and drops its LIFETIME_BOUND annotation.

Everything that could write a partition independently is deleted: setCachePartition(const String&), the static partitionName(const String&) that wrapped PublicSuffixStore::singleton().topPrivatelyControlledDomain, and the setDomainForCachePartition helpers on ResourceRequestBase, CachedResourceRequest, and ScriptExecutionContext. The removal ripples outward in four clusters.

Call sites setting the bit. ThreadableWebSocketChannel::webSocketConnectRequest, DOMURL::revokeObjectURL, InspectorResourceUtilities::cachedResource, CachedResourceLoader::requestUserCSSStyleSheet and requestResource, XMLHttpRequest::createRequest, Internals::resourceFromMemoryCache, SWServer::createScriptRequest, and NetworkResourceLoader::continueWillSendRequest all swap a partition string for setShouldBlockThirdPartyStorage(...). LegacyWebArchive::createInternal gets a real behavioral addition — it now null-checks frame.document() and calls request.setFirstPartyForCookies(document->firstPartyForCookies()), which the derived getter requires and the old partition-only path never supplied.

Loader branches collapsing. DocumentLoader::loadMainResource drops its three-way branch — the subframe case that copied the main document's partition, and the main-frame case that built one from SecurityOrigin::create(mainResourceRequest.resourceRequest().url()) — and reduces to a single bool taken from the document or from settings().storageBlockingPolicy(). AsyncRevalidation and NetworkCacheSpeculativeLoadManager stop re-applying key.partition() onto revalidation requests; the speculative loader instead sets the bit from !key.partition().isEmpty().

Policy unification in ScriptExecutionContext. The m_domainForCachePartition override member is gone, along with the early return that consulted it ahead of the policy check. Page::setupForRemoteWorker no longer writes a partition string; it writes the policy itself via document->setStorageBlockingPolicy(document->settings().storageBlockingPolicy()).

IPC surface. WebCoreArgumentCodersPlatform.serialization.in now serializes bool shouldBlockThirdPartyStorage() in place of String cachePartition(), and WebCore::ServiceWorkerJobData carries bool shouldBlockThirdPartyStorage instead of String domainForCachePartition (constructor signature, isolatedCopy, and both ServiceWorkerContainer job-creation sites follow). The two IPC fuzzing layout tests swap cachePartition: '' for shouldBlockThirdPartyStorage: true accordingly.

On Cocoa, doUpdateResourceRequest stops importing an embedder-supplied partition entirely. The [NSURLProtocol propertyForKey:_kCFURLCachePartitionKey] read is wrapped in dynamic_objc_cast<NSString> and only its emptiness survives (m_shouldBlockThirdPartyStorage = !![cachePartition length]) — enough to detect the bit on requests CFNetwork hands back, such as redirects, which is what http/tests/cache/disk-cache/disk-cache-redirect.html exercises. _kCFURLCachePartitionKey remains the WebKit↔NSURLSession channel inside the Network process; what is gone is treating an app-supplied NSURLRequest's copy of it as authoritative.

Finally, the three cache-deletion helpers that depended on the removed static — MemoryCache::removeResourcesWithOrigin (both overloads) and NetworkProcess::deleteWebsiteDataForOrigin — recompute the partition through RegistrableDomain::uncheckedCreateFromHost(host) with an explicit isEmpty() ? emptyString() : string() fallback. The manually-cached-image CachedImage constructor taking a domainForCachePartition string is deleted outright.

Cache partitioning. WebKit namespaces cached resources by a partition key so that a resource fetched while site A is the top-level page occupies a different cache slot than the same URL fetched under site B. The key is a string. In this diff it appears as the namespace argument to MemoryCache::removeResourcesWithOrigin(origin, partition), as NetworkCache::Key::partition(), and as the traversal filter in cache->traverse(cachePartition, ...).

Two caches, one key. MemoryCache is the in-WebContent resource cache that CachedResourceLoader and MemoryCache::resourceForRequest consult; NetworkCache is the Network process's on-disk HTTP cache. Both participate in ordinary page loads, and both key on the request's partition.

RegistrableDomain. WebCore's wrapper for the "top privately controlled domain" of a host — roughly public-suffix-plus-one (sub.example.co.uk → example.co.uk). PublicSuffixStore::singleton().topPrivatelyControlledDomain(host) is the underlying computation; RegistrableDomain::uncheckedCreateFromHost(host) builds one directly from a host string without re-parsing a URL.

firstPartyForCookies. A field on ResourceRequestBase holding the URL of the party a load is attributed to — for a subresource, the top-level document. Cookie policy and same-site determination already consult it.

StorageBlockingPolicy. A per-context enum (AllowAll, BlockThirdParty, …) expressing whether third-party storage access is permitted. ScriptExecutionContext stores it in m_storageBlockingPolicy, initialized to StorageBlockingPolicy::AllowAll in the constructor and normally overwritten from settings.

ResourceRequest across processes. A ResourceRequest is constructed in WebContent and serialized to the Network process. Its field list lives in Source/WebKit/Shared/WebCoreArgumentCodersPlatform.serialization.in, and the Cocoa variant is rebuilt on the receiving side by ResourceRequest::fromResourceRequestData. Any field declared there is influenceable to whatever degree the sending process is.

_kCFURLCachePartitionKey. A CFNetwork NSURLRequest property key used to convey a cache partition between WebKit and NSURLSession. [NSURLProtocol propertyForKey:inRequest:] returns an untyped id; dynamic_objc_cast<NSString> is WebKit's idiom for checking such a value's class before using it as a string.

Remote worker pages. Service workers run against a synthetic Page/Document assembled by Page::setupForRemoteWorker, which seeds that document with the worker's origin, privacy settings, and referrer policy directly rather than through a navigation. Separately, ServiceWorkerContainer::addRegistration/updateRegistration build a ServiceWorkerJobData in WebContent and send it to the SWServer, which converts it into a script-fetch ResourceRequest in SWServer::createScriptRequest.

This is a denormalization bug: a security-relevant lookup key stored as independently mutable duplicate state instead of being derived from its source of truth.

  Before:                            After:
  ResourceRequest                    ResourceRequest
   ├─ m_firstPartyForCookies ──┐      ├─ m_firstPartyForCookies ──┐
   └─ m_cachePartition         │      └─ m_shouldBlockThirdParty  │
        ▲    ▲     ▲     ▲     │           │                      │
   setCachePartition()  (must   │      cachePartition() ◄──────────┘
   setDomainForCache…()  agree) │        = RegistrableDomain(firstParty)
   NSURLRequest property        │
   originalRequest() copy ──────┘      (no storage → nothing to desync)

The left column is the pre-fix shape. m_firstPartyForCookies and m_cachePartition are both request state, and the codebase requires them to agree — the partition must be the registrable domain of the first party. Nothing enforced that. setCachePartition() and setDomainForCachePartition() were public, so agreement was a per-call-site convention, and the deleted code shows the call sites did not honor it uniformly. DocumentLoader::loadMainResource derived the main-frame partition from SecurityOrigin::create(mainResourceRequest.resourceRequest().url()) — the request's own URL, not its first party. LegacyWebArchive::createInternal set a partition and no first party at all, which is why the fix has to add setFirstPartyForCookies there. NetworkResourceLoader::continueWillSendRequest deliberately copied the pre-redirect partition onto the post-redirect request. AsyncRevalidation and the speculative load manager re-applied key.partition() taken from a cache entry. Four sources, one field.

The one written-down statement of the invariant lived inside the setter the patch deletes:

void ResourceRequestBase::setCachePartition(const String& cachePartition)
{
    ASSERT(!cachePartition.isNull());
    ASSERT(cachePartition == partitionName(cachePartition));
    m_cachePartition = cachePartition;
}

Both lines vanish in release builds — and this is a field that arrives over IPC. WebCoreArgumentCodersPlatform.serialization.in declared String cachePartition() on ResourceRequest, and ServiceWorkerJobData carried String domainForCachePartition for the registration path into SWServer::createScriptRequest. A debug assertion on deserialized data documents an invariant; it does not establish one. The same string had a third inbound path: doUpdateResourceRequest read _kCFURLCachePartitionKey off an embedder-supplied NSURLRequest and assigned the result — an untyped id, with no class check — straight into m_cachePartition.

ScriptExecutionContext carried a second copy of the same duplication, and this one has a concrete divergence visible in the diff:

  setupForRemoteWorker (pre-fix):
    m_storageBlockingPolicy   = AllowAll        ← constructor default, never written
    m_domainForCachePartition = origin->domainForCachePartition()
                                  │
    domainForCachePartition() ────┘  early-returns the override,
                                     never reaching the policy check

domainForCachePartition() returned m_domainForCachePartition whenever that override was non-null, short-circuiting the m_storageBlockingPolicy != StorageBlockingPolicy::BlockThirdParty test below it. Page::setupForRemoteWorker wrote only the override and left m_storageBlockingPolicy at the constructor's AllowAll. A remote-worker document therefore drew its partition from one field while its declared storage-blocking policy said something else — two authorities on third-party storage access, disagreeing by construction. The fix makes setupForRemoteWorker write the policy itself and deletes the override entirely, so the policy is the only authority left.

The consequence of divergence follows directly from what the partition is used for. It is the namespace component of both cache keys — MemoryCache::removeResourcesWithOrigin(origin, originPartition), NetworkCache::Key::partition(), cache->traverse(cachePartition, ...). When the stored partition names a registrable domain other than the one firstPartyForCookies designates, a store or a lookup lands in a different top-level site's partition than the load's actual first-party context, and the boundary that exists to keep site A from observing or populating site B's cached resources does not hold for that request. The primitives that follow are a cross-site cache read oracle — presence and timing of another site's cached resources, and for in-process MemoryCache hits potentially the cached response content — plus the ability to pre-seed a partition belonging to a site the user has not visited. This is user-data disclosure: browsing history and cached response bodies, matching the advisory's impact line. No memory-corruption primitive arises, and no sandbox boundary is crossed; the boundary at risk is a site boundary, and the IPC-string lever presupposes an already-compromised WebContent process. The diff establishes the divergence-capable state and two concrete shapes where it could arise — remote-worker setup, and a partition carried across a redirect — without a demonstrated end-to-end content-reachable divergence, so the rating rests on the bug class rather than a proven chain.

Post-fix, the right column of the diagram holds: the partition has no storage, so there is nothing to desynchronize. One structural detail deserves attention beyond the removal itself. The member default flipped direction. String m_cachePartition { emptyString() } meant a request whose call site forgot to set a partition landed in the shared empty partition — fail-open. bool m_shouldBlockThirdPartyStorage : 1 { true } paired with the derived getter means such a request is partitioned as soon as firstPartyForCookies is set, which the loader sets independently for cookie attribution — fail-closed.

That flip also relocates the attack surface. Where a request's partition was previously reachable through exactly two setters, it is now a pure function of firstPartyForCookies(), so every caller of setFirstPartyForCookies() now influences it — including requests mutated by an embedder delegate through updateFromDelegatePreservingOldProperties() and CFNetwork-generated requests such as redirects that flow through doUpdateResourceRequest. The new getter assumes firstPartyForCookies is itself authoritative and validated wherever it can be written; the commit message states that reasoning ("which should always have the same RegistrableDomain") rather than checking it at the getter. If a path exists that can set firstPartyForCookies to an unrelated site but previously could not touch the partition, cross-partition cache reads would follow from a primitive that formerly only mis-attributed cookies — which makes delegate-modified and redirect-derived requests the area worth auditing after this change.

A cache partition stored as a separately settable string — rather than derived from the request's first party — let a load's cache lookups land in a top-level site's namespace that the load did not belong to.

Cache partitioning is a security boundary implemented as a string key, and this commit is the structural fix for the whole class: stop storing the key, derive it from the principal. That is the direction other engines took for the same reason — Chromium replaced ad-hoc cache keys with a derived NetworkIsolationKey, Firefox derives partitioning from OriginAttributes — because any design where a derived key is also independently settable converts an invariant into a code-review obligation renewed at every call site, and this diff shows four call sites that each computed it from a different source: the request URL, the document's top origin, a cache entry's key, and the pre-redirect request.