[cocoa] _setAllowOnlyPartitionedCookies may not be set on WebSocket requests
CVE: CVE-2026-43708 · Safari 26.5.2 · Released June 29, 2026 Impact: A malicious website may exfiltrate data cross-origin Apple's description: The issue was addressed with improved input validation. Credit: Behzad Najjarpour Jabbari (@G4ru)
Medium — no memory corruption anywhere in this diff, just a privacy control that failed open. But it failed open silently, from ordinary web content, with no preconditions beyond "open a cross-site WebSocket," and the thing that leaked was the victim's unpartitioned cookie jar.
Cookie partitioning is the browser's answer to third-party tracking: when a host is loaded in a third-party context, its cookies are keyed by the top-level site, so tracker.example embedded on a.example cannot see the jar it wrote while embedded on b.example. On Cocoa platforms WebKit implements the opt-in form of this by annotating the outgoing request object with a CFNetwork SPI switch before the loader attaches cookies. NetworkSessionCocoa::createWebSocketTask is the place that annotation happens for WebSocket handshakes — and because the handshake request is built as a lazily materialized mutable copy of the incoming request, the invariant at stake is that every configuration step touches the materialized object rather than the not-yet-created local.
The angle: A page can open a cross-site WebSocket and have the browser attach the destination host's unpartitioned first-party cookies to the handshake, handing the endpoint a cross-site-joinable identity.
Source/WebKit/NetworkProcess/cocoa/NetworkSessionCocoa.mm
Tools/TestWebKitAPI/Tests/WebKit/WKWebView/WKHTTPCookieStore.mm
Patch Details
The production change is one line and one token wide: the receiver of the respondsToSelector: probe changes from the bare local mutableRequest to the accessor call ensureMutableRequest(). Everything inside the guarded block is byte-identical before and after — the same networkStorageSession() fetch, the same isOptInCookiePartitioningEnabled() check, the same thirdPartyCookieBlockingDecisionForRequest(...) == ThirdPartyCookieBlockingDecision::AllExceptPartitioned computation, and the same [mutableRequest _setAllowOnlyPartitionedCookies:shouldAllowOnlyPartitioned] call. Only the gate on the door was replaced; the room behind it is untouched.
Before: After:
ensureMutableRequest() ensureMutableRequest()
└─► request materialized └─► request materialized
[mutableRequest [ensureMutableRequest()
respondsToSelector:] respondsToSelector:]
└─► receiver may be nil ──► NO └─► receiver always non-nil
└─► block SKIPPED └─► real SPI answer
└─► full cookie jar └─► partitioning applied
Note in the diagram that the preceding line already routes through ensureMutableRequest(). That is what makes this a one-token fix rather than a restructuring: the accessor was in scope, already used on the neighboring statement, and simply not used on the probe.
The rest of the commit is test collateral. TEST(WKHTTPCookieStore, WebSocketCookies2) in WKHTTPCookieStore.mm stands up a coroutine-driven HTTPServer proxying three paths. It first navigates the web view to http://siteA.example/com, which responds with a spread of cookies covering every SameSite shape — Default, SameSite_None, SameSite_None_Secure, SameSite_Lax, SameSite_Strict — establishing them first-party for sitea.example. It then navigates to http://siteB.example/ninja, whose body is a single script tag: new WebSocket('ws://siteA.example/websocket'). When the handshake arrives at the server, the test asserts it carries Host: sitea.example (proving the request reached the intended cross-site destination) and, critically, EXPECT_FALSE(contains(request.span(), "Cookie:"_span)) — no cookie header at all. The _setResourceLoadStatisticsEnabled:YES call on the data store is what puts the session into the classification regime where the partitioning decision is meaningful.
Background
The NetworkProcess owns cookies. new WebSocket(url) executes in the WebContent process, but WebContent never touches the cookie jar. The call is forwarded to the NetworkProcess, which constructs the actual request object, applies per-request policy attributes, and hands it to CFNetwork to issue. Every decision described below happens on the NetworkProcess side of that boundary.
A WebSocket handshake is an HTTP request. The opening handshake is an ordinary HTTP GET carrying an Upgrade: websocket header. It goes through the same loader, is subject to the same cookie attachment logic, and — like any subresource load — is first-party or third-party according to the top-level site of the page that initiated it. A WebSocket opened from siteb.example to sitea.example is a third-party load of sitea.example.
Opt-in cookie partitioning. Under partitioning, a host's cookies are keyed by the top-level site under which they were set, so the same host loaded third-party under two different top-level sites sees two disjoint jars. WebCore expresses the per-request verdict through NetworkStorageSession::thirdPartyCookieBlockingDecisionForRequest, which returns a ThirdPartyCookieBlockingDecision; the value AllExceptPartitioned means "block third-party cookies for this load, except partitioned ones." _setAllowOnlyPartitionedCookies: is the CFNetwork request-level switch that communicates that verdict to the loader.
Lazily-materialized mutable request. Rather than unconditionally copying the incoming immutable NSURLRequest into an NSMutableURLRequest, createWebSocketTask defers the copy until some configuration step actually needs to mutate it. Two spellings coexist in the function: ensureMutableRequest(), the create-if-absent accessor, and the bare local mutableRequest, which holds whatever has been created so far — possibly nothing.
respondsToSelector: feature detection. WebKit calls CFNetwork SPI that may not exist on every OS or SDK it builds against, so it probes first: if ([obj respondsToSelector:@selector(_someSPI:)]) [obj _someSPI:value];. When the probe returns NO, the correct and intended fallback is to do nothing.
Objective-C nil messaging. Sending a message to nil is legal in Objective-C; it executes no method body and returns a zero value. For a BOOL-returning method, that zero is NO. There is no exception, no warning, and no way to distinguish the result from a genuine negative answer at the call site.
Analysis
This is a security control that fails open because its enable-gate and its disable-gate produce the same value.
Stack the two prerequisites from Background and the bug writes itself. respondsToSelector: returning NO means "this OS lacks the SPI" — a legitimate, expected condition whose correct handling is to skip the block. A nil receiver also returns NO. So when mutableRequest was still nil at the probe, the guard reported "SPI unavailable," the entire partitioned-cookie block was skipped, and the request went out to CFNetwork with no partitioning annotation at all. The thirdPartyCookieBlockingDecisionForRequest query never ran. _setAllowOnlyPartitionedCookies: was never called. CFNetwork, seeing no instruction to the contrary, attached the full unpartitioned cookie jar for sitea.example to a handshake initiated by siteb.example.
The causal chain, end to end:
- Script on
siteb.examplecallsnew WebSocket('ws://siteA.example/websocket'). - NetworkProcess enters
createWebSocketTask; on this configuration path, nothing has yet calledensureMutableRequest(), so the local is nil. [nil respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]→NO.- Block skipped — no partitioning verdict computed, no switch set.
- CFNetwork attaches
sitea.example's first-party cookies to the outbound handshake. - The WebSocket endpoint reads them from the
Cookie:header.
Step 2 is the subtle one, and it is what makes this bug's shape worth studying. The pre-fix code was not consistently broken. Whether the local was nil at the probe depended entirely on whether some earlier configuration step in the function had already forced materialization — the _privacyProxyFailClosedForUnreachableNonMainHosts assignment sits directly above the probe, but under a condition of its own. On configuration shapes where that branch ran, the object existed and everything worked; on shapes where it didn't, the control silently vanished. This is precisely why the pre-existing WebSocketCookies test passed throughout: it exercised a materialization order that happened to produce a non-nil local. Catching the bug required a second test with a different configuration shape, which is what WebSocketCookies2 is.
What the reader should take from _setResourceLoadStatisticsEnabled:YES and the AllExceptPartitioned comparison is that the security decision the bug discarded was fully computed and correct — WebKit knew this was a third-party load that should carry only partitioned cookies. That verdict just never reached CFNetwork, because the code path that would have asked for it was gated behind a probe that answered NO for reasons having nothing to do with SPI availability. The fix routes the probe through ensureMutableRequest(), so the receiver is guaranteed non-nil and respondsToSelector: measures the one thing it was ever meant to measure: whether CFNetwork implements the selector. The subsequent [mutableRequest _setAllowOnlyPartitionedCookies:] then operates on the same now-materialized object.
The capability this hands an attacker is bounded but real, and it is exactly what Apple's advisory describes as cross-origin exfiltration. Any site can embed script that opens a WebSocket to a target host; the handshake carries that host's unpartitioned first-party cookies to an endpoint the attacker controls or observes. That re-joins a user identity across sites that partitioning was specifically designed to keep disjoint, and it discloses whatever cookie-bound state the target host keeps in that jar. There is no memory-corruption primitive here — the diff changes a message receiver, not a lifetime, bound, or type — and no sandbox escape, since the data travels outward over the network rather than back into the renderer's address space. The gain is entirely in the privacy and origin-isolation domain.
A nil receiver made respondsToSelector: answer "SPI unavailable" instead of "object not yet created," silently disabling cookie partitioning on every cross-site WebSocket handshake that took the unmaterialized path.
Insight
The tell was an asymmetry between two adjacent lines: the statement immediately above the probe already spelled the receiver ensureMutableRequest(), while the probe spelled it mutableRequest. The accessor existed, was in scope, and was used on one of two neighboring lines — which is exactly the kind of difference that survives code review, because both spellings are individually correct-looking and the reviewer's eye reads them as the same thing. Worth carrying: when a probe's negative answer disables enforcement, the probe must be structurally incapable of answering negatively for any reason other than genuine absence.
Audit directions
-
Capability probes on lazily-initialized receivers. The invariant: a feature-detection probe must be evaluated on the same fully-materialized object the guarded operation will act on. Narrow — grep
Source/WebKit/NetworkProcess/cocoaandSource/WebKit/Shared/cocoaforrespondsToSelector:where the receiver is a bare local rather than anensure*()accessor call;NetworkDataTaskCocoa.mm,NetworkSessionCocoa.mm, and the download /WKURLSessionTaskDelegatepaths all use the same lazymutableRequestidiom for regular loads and redirects. Wider — the class generalizes to any probe-then-act pair whose probe target can be a default-valued sentinel:-conformsToProtocol:,-isKindOfClass:,[obj class]dispatch, C++if (ptr && ptr->supportsX())whereptris lazily created, and soft-linked symbol checks (getSomeClass() != nil) evaluated before the framework loads. Match tell on both rungs: probe receiver and operation receiver spelled differently on adjacent lines. Widest — lazy initialization plus capability detection plus a fail-open default is a security-control-skipping triad in any language. Chromium'sbase::FeatureListchecks read before feature initialization, Java'sOptional.map(...).orElse(false)gating an authz branch, and Python'sgetattr(obj, 'check', None)against a partially-constructed object all reproduce it exactly. -
Other privacy attributes on the WebSocket path. Narrow — enumerate every attribute
createWebSocketTasksets on the outgoing request (_privacyProxyFailClosedForUnreachableNonMainHosts,_setAllowOnlyPartitionedCookies:, plus any enhanced-privacy-mode or tracker-blocking flags in scope) and confirm each is applied throughensureMutableRequest()rather than the raw local. Wider — diff that list against the attributes applied to ordinary loads inNetworkDataTaskCocoa.mm; match tell is any privacy attribute set on HTTP loads but absent on the WebSocket path, since WebSocket is a frequently-forgotten second request type that carries the same cookie jar. Ceiling — this rung is WebKit-specific: the attribute inventory is defined by CFNetwork's request SPI surface, so there is no meaningful out-of-WebKit generalization beyond the probe pattern already covered above. -
Policy applied per-request-type instead of at a choke point. The question to ask: does WebKit consult
thirdPartyCookieBlockingDecisionForRequestuniformly across every outbound request class, or is it re-implemented at each call site? Narrow — trace all callers ofWebCore::NetworkStorageSession::thirdPartyCookieBlockingDecisionForRequestandisOptInCookiePartitioningEnabledand check which request kinds are represented: WebSocket, WebTransport, EventSource/SSE,fetchwithkeepalive, Beacon, prefetch/preconnect, service-worker-initiated fetches, download and redirect continuations. Wider — the same shape recurs for any policy that must hold across a family of network entry points: CSPconnect-srcenforcement, ITP/tracker classification, proxy selection,Sec-Fetch-*metadata population. Match tell: a policy helper whose caller list is shorter than the list of request kinds the process can originate. Widest — wherever an authorization or privacy decision is consulted by callers rather than enforced by the transport, the audit is mechanical: enumerate every code path that can emit a request, diff against the set that consults the policy. Same reasoning covers server-side middleware bypassed by a non-HTTP admin channel, or a database layer where one ORM path skips row-level security. -
Which configuration shapes leave the local nil. Since reachability depended on which earlier steps had materialized
mutableRequest, the pre-fix bug may have been reachable through more than a plain script-initiated WebSocket. Narrow — examine the branches preceding the partitioned-cookie block increateWebSocketTask, including the advanced-privacy-protections /_privacyProxyFailClosedForUnreachableNonMainHostspath and any conditional early return, and determine which combinations of session configuration, proxy setup, and page settings leave the local nil at the probe. Wider — the general match tell is anyensure*()call site sitting inside anif: every such call means the object's existence at later lines is runtime-configuration-dependent, and every later bare-local use is a candidate for the same failure. Ceiling — verification here is hard from source alone and likely warrants an instrumented build or one API test per configuration shape, which is precisely whyWebSocketCookies2had to be added alongside the existingWebSocketCookiesrather than extending it.