Live › vulnerability › CVE-2026-118666
Go to dashboardA reused executor from @graphql-tools/executor-legacy-ws shares one WebSocket and overwrites its message handler on every subscribe, and it delivers DATA frames without checking the operation id. A second caller using the same executor while another subscription is open receives the first caller's payloads, and the first caller's stream stops.
Summary written from the sources below. Check the originals before acting.
First seen by InfraStack 2026-10-28 21:07 UTC, 0h before NVD. All times on this page are UTC.
Applications that build one legacy WebSocket executor with buildWSLegacyExecutor and reuse it for overlapping subscriptions from different callers, such as gateways and stitched schemas. Consumers that create a new executor for each operation are not affected by the cross-caller leak. Whether other consumers are affected is unknown.
| Product | Ecosystem | Affected | Fixed in |
|---|---|---|---|
| @graphql-tools/executor-legacy-ws | npm | <= 1.1.35 | 1.1.36 |
npm ls @graphql-tools/executor-legacy-ws to find the installed version.buildWSLegacyExecutor and check whether one returned executor is reused across requests or users.wrapSchema or a stitched gateway for subscriptions.@graphql-tools/executor-legacy-ws to 1.1.36 (the advisory's fixed version).Each row is the first time that source carried this signal. The analyst’s rows are its own findings.
@graphql-tools/executor-legacy-ws: a shared websocket.onmessage is replaced on every subscribe(), and DATA frames are delivered without checking the protocol operation id| Field | Value |
|---|---|
| Package | @graphql-tools/executor-legacy-ws (npm, pkg:npm/%40graphql-tools/executor-legacy-ws) |
| Repository | https://github.com/ardatan/graphql-tools |
| Component | buildWSLegacyExecutor → the returned executor's subscribe(observer) / websocket.onmessage handler |
| Vulnerability class | Cross-tenant data leak (subscription results delivered to the wrong caller) |
| Verified against | Source at b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1 (package version 1.1.33); published npm tarball 1.1.33 with matching source at d304214db06e8a4ce9cb41138f199e3bc6ed36f2; the same behaviour also reproduces on the current release 1.1.35 |
| API surface | Public documented API (buildWSLegacyExecutor) |
| Affected in default configuration | Yes — ensureWebsocket() reuses an already-OPEN socket (index.ts:35-42), and the socket has a single mutable onmessage property (index.ts:113). No single-operation-only restriction is documented, and the return type is the general reusable DisposableExecutor (packages/utils/src/executor.ts:34-57) |
Affected version range: the range recorded for this finding is <= 1.1.33, but that is understated — the same proof of concept reproduces on 1.1.35, and a byte diff of the built CJS output between 1.1.33 and 1.1.35 shows only one unrelated change (rejectUnauthorized).
buildWSLegacyExecutor keeps one WebSocket in closure state and installs the message handler by assigning to the socket's single websocket.onmessage property inside subscribe(). Every new subscription therefore overwrites the previous subscriber's handler, and the handler's case LEGACY_WS.DATA branch calls observer.next(data.payload) without ever comparing data.id to the operation id that this subscription created at line 103 and sent with START at line 123. When one executor instance is reused for overlapping subscriptions, the most recent caller receives payloads belonging to the earlier caller, and the earlier caller receives nothing.
In a consuming application, the tainted input is an ordinary, correctly-tagged DATA frame emitted by the legitimate downstream server for the victim's operation. The attacker does not need to control or compromise that server; they only need to be able to start their own subscription through the same shared executor while the victim's subscription is active — the natural shape of a gateway, stitched schema, or any service that builds a remote executor once and reuses it per request.
The surrounding code shows the inconsistency:
packages/loaders/url/src/index.ts:262-295 (in-repo UrlLoader wrapper) → constructs a fresh legacy executor inside each request, so that caller avoids the shared state.buildWSLegacyExecutor's returned executor, reused across callers → unsafe: shared onmessage overwritten at line 113, DATA dispatched without an id check at lines 145-147.packages/loaders/url/src/index.ts:172-182 → also a production caller of the legacy executor; safe only insofar as the wrapper above rebuilds the executor per request, not because of any check in the executor itself.A user of a service that shares one legacy executor receives another user's private GraphQL subscription payloads verbatim, with the victim's authorization context, without ever having been authorized for that operation. The victim is simultaneously starved: their own iterator stops receiving the events they subscribed to, because their handler has been detached.
Without this bug, the attacker's observer would receive only results for their own operation — and in this implementation, no result at all, since their operation is never STARTed after CONNECTION_ACK has already fired. The new capability is therefore reading data the attacker could not otherwise obtain by any request they are permitted to make, plus a denial of the victim's subscription stream.
Scoring note: no CVSS vector was computed for this finding, because exploitability depends on the consuming application's data flow — specifically whether one executor instance is shared across mutually untrusted callers. Consumers that construct a new executor per operation (as the in-repo UrlLoader wrapper does at packages/loaders/url/src/index.ts:262-295) are not affected by the cross-caller leak. The set of affected downstream consumers has not been enumerated: unknown.
buildWSLegacyExecutor is reused rather than constructing a new executor per operation. This is the normal use of an Executor: the contract is invoked once per ExecutionRequest, the code explicitly reuses an OPEN socket, and the returned value holds Symbol.dispose.OPEN socket. The deciding default is in ensureWebsocket() (packages/executors/legacy-ws/src/index.ts:35-42), which does not create a second socket when one is open, combined with the single mutable websocket.onmessage assignment at line 113.This does not require non-standard usage, monkey-patching, or unusual configuration. packages/executors/legacy-ws/src/index.ts documents no single-operation-only restriction, the return type is the general reusable DisposableExecutor (packages/utils/src/executor.ts:34-57), and the behaviour is not documented as a caller responsibility.
DATA message emitted by the downstream server for another operation on the shared WebSocket. The victim invokes the returned executor, and subscribe() installs a closure over the victim's observer and request as websocket.onmessage (packages/executors/legacy-ws/src/index.ts:90-113).CONNECTION_ACK starts the victim's operation with id, created at line 103 and sent with START at line 123 (packages/executors/legacy-ws/src/index.ts:116-135).websocket.readyState is OPEN; ensureWebsocket() does not create a new socket and subscribe() replaces the sole websocket.onmessage with the attacker's closure (, ). The server's frame for the victim carries the victim's operation id per the legacy protocol, but the handler ignores and calls on the attacker's observer ().This page as .md · .json · in your agent: get_signal("CVE-2026-118666")
GitHub Advisory Database, CC-BY 4.0 · NVD data is provided by NIST and is public domain. Posts quoted belong to their authors.
index.ts:35-42DATAdata.idobserver.next(data.payload)subscribe path of the executor produced by the directly exported buildWSLegacyExecutor ([index.ts:27-31](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/le