---
id: "CVE-2026-118666"
kind: "vulnerability"
aliases: ["CVE-2026-118666", "GHSA-mp66-8ww2-pcwm"]
title: "GraphQL Tools: Shared legacy executor routes one subscription's results to a different caller"
severity: "medium"
cvss: 6.0
epss: null
exploited: false
status: "open"
category: "package"
products: [{"name": "@graphql-tools/executor-legacy-ws", "fixed": ["1.1.36"], "affected": "<= 1.1.35", "ecosystem": "npm"}]
ecosystems: ["npm"]
tags: ["cwe-200", "gateways", "graphql-subscriptions", "graphql-tools", "node", "npm", "package", "stitched-schemas"]
not_affected_if: ["new-executor-per-operation", "no-legacy-ws-subscriptions"]
published_at: "2026-10-28T21:05:07+00:00"
first_seen_at: "2026-10-28T21:07:44+00:00"
updated_at: "2026-10-28T21:35:45+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-mp66-8ww2-pcwm", "first_seen_at": "2026-10-28T21:07:44+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-118666", "first_seen_at": "2026-10-28T21:35:45+00:00"}]
---

# GraphQL Tools: Shared legacy executor routes one subscription's results to a different caller

A 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, check and fix written by a model from the sources; labelled as such._

## Affected

- npm:@graphql-tools/executor-legacy-ws affected <= 1.1.35 fixed 1.1.36

## How to check

- Run `npm ls @graphql-tools/executor-legacy-ws` to find the installed version.
- Search your code for `buildWSLegacyExecutor` and check whether one returned executor is reused across requests or users.
- Check whether that executor is used with `wrapSchema` or a stitched gateway for subscriptions.

## Fix

- Upgrade `@graphql-tools/executor-legacy-ws` to `1.1.36` (the advisory's fixed version).
- Until upgraded, build a new executor per operation or per user instead of sharing one instance.

## Timeline

- 2026-10-28T21:07:44+00:00 ghsa https://github.com/advisories/GHSA-mp66-8ww2-pcwm
- 2026-10-28T21:35:45+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-118666

## From the sources

# Cross-tenant subscription data leak in `@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

## Report metadata

| 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`).

## Summary

`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.

## Impact

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 `START`ed 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**.

## Preconditions

1. One executor value returned by `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`.
2. Two subscription iterators overlap on an already-`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.
3. The attacker can cause their own subscription request to be made through that shared executor while the victim's subscription is active and acknowledged. They do not need any control over the downstream server.
4. Downstream subscription results differ by user or authorization context, so a misrouted payload is actually a disclosure.

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 flow

1. **Source** — a legitimate `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`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L90-L113)).
2. **Propagation** — the server's `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`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L116-L135)).
3. **Sink** — the attacker invokes the same executor while `websocket.readyState` is `OPEN`; `ensureWebsocket()` does not create a new socket and `subscribe()` replaces the sole `websocket.onmessage` with the attacker's closure ([`index.ts:35-42`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L35-L42), [`index.ts:109-113`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L109-L113)). The server's `DATA` frame for the victim carries the victim's operation id per the legacy protocol, but the handler ignores `data.id` and calls `observer.next(data.payload)` on the attacker's observer ([`index.ts:114-147`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L114-L147)).
4. **Reachability** — the sink sits in the only `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

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
