[6] Third-party cookies not blocked for DedicatedWorker WebSocket connections
Move the beacon into a Worker and the blocked cookies come back.
Medium, and it is a privacy verdict rather than a memory-safety one: a tracker embedded on any page gets its cross-site cookies back simply by moving its beacon into a worker. No memory corruption, no process boundary crossed — but it is reachable from ordinary web content with no preconditions at all.
Intelligent Tracking Prevention suppresses cookies on requests to a domain that is not the first party of the current page, so a third-party resource does not receive the identifiers it set in a first-party context. That decision is made in the network process, close to where the request is issued, and it depends on the request's originating context being available as an input. A DedicatedWorker runs script on its own thread with its own global scope and can issue network requests on the owning document's behalf; because a WebSocket channel is document-bound and must live on the main thread, a worker's WebSocket is implemented as a Bridge on the worker thread paired with a Peer on the main thread. The expectation is that the same blocking policy applies to every subresource path that can reach a cross-site server.
The angle: a tracker embedded as a third party on a victim page can move its beacon to a worker-hosted WebSocket and receive its own cookies in the handshake, restoring the cross-site identity linkage the user's blocking policy removed.
DedicatedWorker WebSocket connections were not being subjected to ITP third-party cookie blocking, unlike DedicatedWorker fetch requests which already carried isInitiatedByDedicatedWorker through NetworkResourceLoadParameters. This patch threads a new isInitiatedByDedicatedWorker boolean from the worker thread through WorkerThreadableWebSocketChannel::Bridge::initialize(), where it is derived via is<DedicatedWorkerGlobalScope>(scope), into the WebSocketTaskCocoa constructor, where it replaces the previous shouldBlockCookies() call with thirdPartyCookieBlockingDecisionForRequest(..., isInitiatedByDedicatedWorker).
Source/WebCore/Modules/websockets/WorkerThreadableWebSocketChannel.cpp
Source/WebCore/Modules/websockets/WorkerThreadableWebSocketChannel.cpp
Source/WebKit/NetworkProcess/NetworkConnectionToWebProcess.messages.in
LayoutTests/http/tests/websocket/tests/hybi/resources/websocket-blocked-sending-cookie-as-third-party-worker.js
Patch Details
The change introduces a typed provenance flag in WebCore and threads it through the WebSocket parameter chain and the IPC boundary into the network process.
On the WebCore side, a new enum class IsInitiatedByDedicatedWorker : bool { No, Yes } lands in Source/WebCore/Modules/websockets/IsInitiatedByDedicatedWorker.h. ThreadableWebSocketChannel::create(Document&, WebSocketChannelClient&, SocketProvider&) gains a fourth parameter defaulted to IsInitiatedByDedicatedWorker::No, and SocketProvider::createWebSocketChannel and EmptySocketProvider::createWebSocketChannel gain the same. Bridge::initialize(WorkerGlobalScope& scope) computes the value via is<DedicatedWorkerGlobalScope>(scope), captures it in the postTaskToLoader lambda, and passes it through mainThreadInitialize() → Peer::create() → Peer::Peer() → ThreadableWebSocketChannel::create(downcast<Document>(context), ...) on the main thread.
On the WebKit side, the flag is added to the CreateSocketChannel IPC message and carried through NetworkConnectionToWebProcess::createSocketChannel → NetworkSocketChannel::create/constructor → NetworkSession::createWebSocketTask. Per the commit message, the Cocoa WebSocketTask constructor replaces its previous shouldBlockCookies() call with thirdPartyCookieBlockingDecisionForRequest(..., isInitiatedByDedicatedWorker); that specific swap is relayed as-is, because the WebSocketTaskCocoa.mm hunks fall in the truncated portion of the supplied diff. Collateral: three new layout test files exercising a cross-origin WebSocket handshake from inside a Worker, and LayoutTests/ipc/create-socket-channel-invalid-url-crash.html updated to append isInitiatedByDedicatedWorker: 0 so its hand-constructed IPC message still matches the serialized argument list.
Policy decision made at a layer where the originating context has already been erased, so a privacy control applied on one request path is silently skipped on a sibling path reaching the same sink.
Background
ITP third-party cookie blocking. WebKit's Intelligent Tracking Prevention can suppress cookies on requests to a domain when that domain is not the first party of the current page, so a third-party resource does not receive the identifiers it set in a first-party context. The decision is made in the network process, close to where the request is issued.
WebSocket handshake. A WebSocket connection begins as an ordinary HTTP request with an Upgrade: websocket header. Like any HTTP request it is subject to cookie policy — cookies for the destination origin are attached according to the applicable rules.
DedicatedWorker / DedicatedWorkerGlobalScope. A DedicatedWorker runs script on its own thread with its own global scope object, distinct from the page's Window, and can issue network requests (fetch, WebSocket) on behalf of the owning document.
Bridge/peer split. Because the real WebSocket channel is document-bound and must live on the main thread, a worker's WebSocket is a Bridge on the worker thread paired with a Peer on the main thread. Bridge::initialize() posts a task to the loader thread via postTaskToLoader, blocks on a BinarySemaphore, and the main thread constructs the Peer, which constructs a real ThreadableWebSocketChannel against the owning Document.
SocketProvider. The WebCore-level abstraction that hands out WebSocket channel implementations; WebSocketProvider (modern WebKit), LegacySocketProvider (WebKitLegacy), and EmptySocketProvider (no-op) all implement it.
Cookie domain scoping. Cookies are keyed by registrable domain, so localhost and 127.0.0.1 are distinct cookie stores even though both resolve to the loopback interface — a page on one cannot read the other's cookies through document.cookie under any policy.
NetworkConnectionToWebProcess.messages.in. The declarative IPC message definition file for the Web-process-to-Network-process connection; each message's parameter list defines the serialized wire format, so changing it requires every sender and hand-built test message to match.
Analysis
The root cause is that the blocking decision was made at a layer where the request's originating context had already been erased. The WebSocket parameter chain simply had no field for it: SocketProvider::createWebSocketChannel(Document&, WebSocketChannelClient&) took only the Document, Peer constructed the main-thread channel from downcast<Document>(context) — the owning document — with no record that the request originated inside a worker global scope, and by the time the request crossed IPC into createSocketChannel the worker provenance was structurally unrepresentable in the message signature.
Before: After:
DedicatedWorkerGlobalScope DedicatedWorkerGlobalScope
| Bridge::initialize() | is<DedicatedWorkerGlobalScope> -> Yes
v postTaskToLoader v postTaskToLoader (flag captured)
Peer: downcast<Document>(context) Peer: downcast<Document>(context) + flag
v CreateSocketChannel IPC v CreateSocketChannel IPC (+ flag)
NetworkProcess: shouldBlockCookies() NetworkProcess:
^ worker provenance erased thirdPartyCookieBlockingDecision(.., flag)
As the left column shows, the Peer hop is where the provenance dies: the context is narrowed to the owning Document before anything downstream can ask what kind of global scope issued the request. Consequently the handshake Upgrade request could carry cookies for the cross-origin destination that ITP's worker-aware policy would otherwise have stripped. The fix makes the distinction explicit at the one point where it is cheaply knowable — on the worker thread — and plumbs it as a typed enum rather than reconstructing it downstream.
Reachability requires no special conditions: any page can construct a Worker, and script inside a DedicatedWorkerGlobalScope can call new WebSocket("ws://cross-origin-host/..."). The regression test walks the concrete sequence: the page sets setAsFirstPartyHTTPLoopback at its initial origin, navigates to http://localhost:8000/...#setCookieAsFirstParty and sets setAsFirstPartyHTTP and setAsFirstPartyJS as first-party cookies for localhost, then navigates to http://127.0.0.1:8000/...#didSetCookieAsFirstParty — putting localhost into the third-party position — and spawns the worker, which opens ws://localhost:8880/....
The shouldBeUndefined(document.cookie) line in that last step carries no weight: the expected output records PASS is undefined. with an empty expression name, showing the helper received the value rather than the expression string, and in any case localhost cookies would not appear in document.cookie at 127.0.0.1:8000 regardless of ITP because those are distinct cookie domains. The load-bearing assertion is the worker step. From the worker script's own message strings, the test endpoint accepts the handshake only when no cookies are present, so onopen is the PASS signal and onerror ("Connection was rejected (request contained cookies)") is the pre-fix failure signal; the server-side handler is not part of the supplied diff.
This vulnerability weakens the ITP third-party cookie-blocking boundary — the anti-tracking policy that prevents a cross-site resource from receiving the user's cookies for its own origin when loaded from an unrelated first party. The assumption at stake is that the blocking decision is uniform across every subresource path that can reach a cross-site server. Before the fix, a tracker embedded on a page could open a WebSocket from inside a DedicatedWorker and would likely receive its own cookies in the handshake, restoring cross-site identity linkage, with the incidental effect that any session cookie for the tracker's origin could be exposed to it in a context the policy had disabled. There is no memory-safety or process-isolation consequence. Note that the flag is supplied by the WebContent process, so a fully compromised WebContent process could send No for a genuine worker request and recover the pre-fix behavior — but an attacker at that level already has broader cookie access, so no existing boundary is extended.
Insight: the fix had to be made where it was because the worker-vs-document distinction is only cheaply knowable on the worker thread, while the policy decision happens in the network process, several layers and one IPC boundary away — and the intermediate Peer deliberately erases the worker by downcasting to the owning Document. Any request-provenance signal in this stack must be captured at the top and carried explicitly; anything reconstructed downstream from the Document alone will be wrong for worker-initiated requests by construction. Worth noting too: ThreadableWebSocketChannel::create(Document&, ...) gives the new parameter a default of IsInitiatedByDedicatedWorker::No, and the ScriptExecutionContext& overload still calls the document overload without an argument, so any future path that acquires worker-like semantics will silently default to the un-flagged behavior rather than failing to compile.
Audit directions
- A privacy or security policy with two or more request paths to the same enforcement point, where provenance metadata was added to one path's parameter bundle but not the other's. Every path that reaches a policy sink must carry the same decision inputs; this breaks silently because the un-plumbed path compiles and runs fine — it just decides differently. Narrow: enumerate the fields of the resource-load parameter bundle that feed ITP/privacy decisions and diff them against the
CreateSocketChannelparameter list inNetworkConnectionToWebProcess.messages.inand against the WebTransport equivalents inSocketProvider::initializeWebTransportSession; each field present in one and absent in the other is a candidate. Wider: subsystems with a main request pipeline plus bolt-on protocols with hand-rolled parameter lists — EventSource,sendBeacon, preconnect/prefetch hints, ServiceWorker-mediated loads. Widest: the general policy-input-dropped-on-a-sibling-path class, e.g.network::ResourceRequesttrusted-params fields, or any RPC schema where a security-relevant field was appended to one message but not its siblings. Match tell on every rung: a policy function with an optional or defaulted context parameter, plus at least one caller that omits it. - Security- or privacy-relevant parameters given default arguments at an API boundary.
ThreadableWebSocketChannel::create(Document&, WebSocketChannelClient&, SocketProvider&, IsInitiatedByDedicatedWorker = IsInitiatedByDedicatedWorker::No)is the shape: a defaulted enum whoseNovalue means "apply the weaker policy". Verify for each that no production caller relies on the default when the safe answer is unknown rather than genuinely negative. Wider: searchSource/WebCoreandSource/WebKitfor= false)and= std::nullopt)in signatures whose name containsPolicy,Blocking,Allowed,Trusted, orOrigin. Widest: a defaulted parameter encoding a security decision converts an omission bug from a compile error into a silent policy downgrade — ask, in any codebase, "if a new caller forgets this argument, do they get the strict or the lax behavior?" Code-review tell: the default value is the less restrictive of the enum's two states. - Context downcast across a thread or process hop, followed by re-derivation of a property the downcast destroyed.
Peer::Peerdoesdowncast<Document>(context)— a deliberate provenance erasure at a thread hop — and any feature that reconstructs policy inputs from thatDocumentafterwards will be wrong for workers. Start with the other main-thread proxy objects reached viaWorkerLoaderProxy::postTaskToLoaderand check whether each recomputes any policy-relevant property from the document rather than receiving it from the worker side. Wider: anywhere aScriptExecutionContextis narrowed to aDocumentbefore a decision, and anywhere a worker's own origin, CSP, or referrer policy is assumed equal to the owner document's. Widest: in any architecture with a proxy translating a request from one context into another, anything the policy layer needs must be passed as data, never re-derived from the proxy's own context. Code-review tell: adowncast</static_cast<narrowing on the receiving side of a cross-thread post, with a policy call downstream of it. - Trusted privacy flags on the WebContent→Network IPC boundary.
NetworkConnectionToWebProcess::createSocketChannelcurrently appliesMESSAGE_CHECKtorequest.url().isValid()and toallowsFirstPartyForCookies(...)but treats the new flag as trusted input. Investigate whether the other privacy flags on the same message (hadMainFrameMainResourcePrivateRelayed,allowPrivacyProxy,storedCredentialsPolicy) are independently validated or similarly trusted, and whether a consistent policy exists. This class is bound to WebKit'sMESSAGE_CHECKidiom and its specific WebContent/Network trust split, so the audit ceiling is WebKit's IPC boundary. Match tell: a security- or privacy-relevant enum or bool in a.messages.indeclaration that the handler forwards without a correspondingMESSAGE_CHECKor server-side re-derivation.