← All reports

[cocoa] _setAllowOnlyPartitionedCookies may not be set on WebSocket requests

MediumWebKit NetworkProcess, Cocoa networking layerCrossOrigin

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)

971435f | Bugzilla 315306

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

ensureMutableRequest().get()._privacyProxyFailClosedForUnreachableNonMainHosts = YES;
 
#if ENABLE(OPT_IN_PARTITIONED_COOKIES) && defined(CFN_COOKIE_ACCEPTS_POLICY_PARTITION) && CFN_COOKIE_ACCEPTS_POLICY_PARTITION
- if ([mutableRequest respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]) {
+ if ([ensureMutableRequest() respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]) {
if (CheckedPtr storageSession = networkStorageSession(); storageSession && storageSession->isOptInCookiePartitioningEnabled()) {
bool shouldAllowOnlyPartitioned = storageSession->thirdPartyCookieBlockingDecisionForRequest(request, frameID, pageID, networkProcess().shouldRelaxThirdPartyCookieBlockingForPage(webPageProxyID), isRequestToKnownCrossSiteTracker(request)) == WebCore::ThirdPartyCookieBlockingDecision::AllExceptPartitioned;
[mutableRequest _setAllowOnlyPartitionedCookies:shouldAllowOnlyPartitioned];

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

+TEST(WKHTTPCookieStore, WebSocketCookies2)
+{
+ if (path == "http://sitea.example/com"_s) {
+ co_await connection.awaitableSend(
+ "HTTP/1.1 200 OK\r\n"
+ "Set-Cookie: Default=1\r\n"
+ "Set-Cookie: SameSite_None=1; SameSite=None\r\n"
+ "Set-Cookie: SameSite_None_Secure=1; secure; SameSite=None\r\n"
+ "Set-Cookie: SameSite_Lax=1; SameSite=Lax\r\n"
+ "Set-Cookie: SameSite_Strict=1; SameSite=Strict\r\n"
+ ...
+ } else if (path == "ws://sitea.example/websocket"_s) {
+ EXPECT_TRUE(contains(request.span(), "Host: sitea.example"_span));
+ EXPECT_FALSE(contains(request.span(), "Cookie:"_span));
+ receivedThirdRequest = true;
+ } else if (path == "http://siteb.example/ninja"_s) {
+ auto html = @"<script>new WebSocket('ws://siteA.example/websocket')</script>";
+ co_await connection.awaitableSend(HTTPResponse(html).serialize());
+ }
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://siteA.example/com"]]];
+ [webView _test_waitForDidFinishNavigation];
+ [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"http://siteB.example/ninja"]]];
+ Util::run(&receivedThirdRequest);

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.

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.

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:

  1. Script on siteb.example calls new WebSocket('ws://siteA.example/websocket').
  2. NetworkProcess enters createWebSocketTask; on this configuration path, nothing has yet called ensureMutableRequest(), so the local is nil.
  3. [nil respondsToSelector:@selector(_setAllowOnlyPartitionedCookies:)]NO.
  4. Block skipped — no partitioning verdict computed, no switch set.
  5. CFNetwork attaches sitea.example's first-party cookies to the outbound handshake.
  6. 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.

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.