GHSA-434m-5hcj-pxf3: Npm/@graphql-tools/executor-legacy-ws vulnerability

Published Oct 28, 2026
·
Updated

Stale authentication state in @graphql-tools/executor-legacy-ws: per-request extensions.connectionParams persist in executor closure state and are replayed in a later caller's connectioninit

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 (packages/executors/legacy-ws/src/index.ts) | | Vulnerability class | Stale authentication state / credential reuse across requests | | Verified against | Commit b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1; npm release 1.1.33 installed and exercised. During validation the same behaviour was also observed on the current latest release 1.1.35, and the unreset closure is still present on master. | | API surface | Public documented API (buildWSLegacyExecutor, and request.extensions.connectionParams on the returned executor) | | Affected in default configuration | Yes — the factory returns a general, reusable Executor with no per-identity or single-use restriction, and no supported teardown clears the retained parameters. |

Version note: the originally recorded affected range was <= 1.1.33. Validation showed that range understates exposure — the same proof of concept leaks identically on 1.1.35.

Summary

buildWSLegacyExecutor keeps a single closure-scoped object, executorConnectionParams, for the lifetime of the executor. Every execution that carries request.extensions.connectionParams merges those keys into that object with Object.assign, and the object is merged into the connectioninit payload each time a new WebSocket opens. Nothing ever removes keys: cleanupWebsocket terminates the socket and nulls the handle but leaves executorConnectionParams intact, and a later request that simply omits a key skips the conditional update entirely.

The consumer-side shape is a shared executor that forwards per-user credentials. When one long-lived executor serves multiple principals — the schema-stitching, gateway, or mesh pattern where each ExecutionRequest supplies its own extensions.connectionParams — a request that omits the auth key is sent to the downstream server with the previous caller's Authorization/tenant values in its connectioninit, so the downstream server authenticates the later caller as the earlier user.

Behaviour of the neighbouring state in the same file:

- WebSocket handle (websocket) → reset on teardown in cleanupWebsocket, lines 78-88 - Per-request connectionParams merge, present case → applied as documented, lines 93-101 - executorConnectionParams closure object → never reset; survives socket teardown and is re-sent on the next connection, line 32 and line 54

Impact

A caller who supplies no credentials (or lower-privileged ones) can have its downstream GraphQL subscription executed under a previous user's retained token or tenant value, and can receive data that its own connectioninit would not authorize. The credential itself is never disclosed to the attacker — it reaches the sink through closure storage, not because the attacker knows it — but the downstream authorization it represents is.

The new capability versus safe behaviour: without this bug, an anonymous request would open a connection whose connectioninit contains only base options plus that request's own parameters, and the downstream server would treat it as anonymous. With it, the anonymous request inherits the victim's authorization. Validation also showed partial-overwrite merging: a later caller supplying only {tenant: "<attacker tenant>"} gets the victim's Authorization retained alongside its own tenant, i.e. an attacker-chosen tenant authenticated with someone else's bearer token.

Scoring note: not scored. No CVSS vector accompanies this finding, and exploitability depends on the consuming application's data flow rather than on the library alone. Consumers that construct a fresh executor per request are not affected — the in-repository UrlLoader wrapper (packages/loaders/url/src/index.ts:172-182) builds a new executor for each request and therefore does not exhibit the persistence. The exposure is to applications that use the exported factory directly and share the executor.

Preconditions

1. One executor instance created by buildWSLegacyExecutor is reused across requests belonging to different users or authorization contexts. 2. The application uses the explicitly supported extensions.connectionParams field to carry per-request downstream identity. This field is documented only by the source comment at line 91, which says additional connection params can be supplied through extensions but does not warn that values persist into later requests. 3. The first operation's socket is closed before the later operation opens a new one. This is the normal path, not an edge case: the COMPLETE handler calls cleanupWebsocket() after every operation. 4. Attacker capability required: the ability to make a request through the sharing application — in a gateway, a remote user controls whether they authenticate and which subscription they ask for, and an anonymous request naturally leaves the connectionParams key absent. 5. The later request omits at least one sensitive key rather than overwriting every prior key (Object.assign merges, it does not replace).

This does not require non-standard usage, monkey-patching, or unusual configuration: the affected path is the documented extensions.connectionParams feature on the package's public API, and the library does not document per-request credential isolation as a caller responsibility. Validation confirmed that the library's own documented teardown, executorSymbol.dispose (bound to cleanupWebsocket), does not clear the state either — no supported API resets it.

Data flow

1. Source — a victim ExecutionRequest supplies extensions.connectionParams, commonly an Authorization or tenant token (packages/executors/legacy-ws/src/index.ts:90-100). 2. Propagation — Object.assign stores those keys in the closure-scoped executorConnectionParams (index.ts:32, index.ts:97-100). 3. Propagation — cleanupWebsocket terminates the socket but does not reset executorConnectionParams (index.ts:78-88); the attacker's later request omits connectionParams, so the conditional update is skipped and the old keys remain (index.ts:93-101). 4. Sink — the new socket's onopen merges the retained state into the connectioninit payload and sends it before the attacker's operation: payload = Object.assign(payload, executorConnectionParams); websocket!.send(...connectioninit payload...) (index.ts:44-65, index.ts:120-129). 5. Reachability — the package directly exports buildWSLegacyExecutor (index.ts:27), and the entry point is the returned disposable executor plus request.extensions.connectionParams.

Vulnerable code

packages/executors/legacy-ws/src/index.ts, lines 32-100 at b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1:

ts let executorConnectionParams = {}; // closure state, lives as long as the executor ... payload = Object.assign(payload, executorConnectionParams); // sink: retained keys enter connectioninit websocket!.send(JSON.stringify({ type: LEGACYWS.CONNECTIONINIT, payload })); ... if (request.extensions?.['connectionParams'] && typeof request.extensions?.['connectionParams'] === 'object') { executorConnectionParams = Object.assign(executorConnectionParams, request.extensions['connectionParams']); } // no else branch: a request that omits connectionParams leaves the previous request's keys in place, // and cleanupWebsocket (lines 78-88) resets only websocket, never executorConnectionParams.

Reproduction

Verified dynamically against the real package, not by static review alone. Release 1.1.33 was installed from npm and driven against a live local ws server implementing the legacy graphql-ws protocol, recording the connectioninit payload of every physical connection.

Observed across the validation runs, using one shared executor instance:

- Request 1 (victim) sent extensions.connectionParams {Authorization: "Bearer VICTIM-ALICE-SECRET-TOKEN", tenant: "alice-tenant"}. After that subscription completed, the executor sent connectionterminate and tore the socket down. - Request 2 supplied no connectionParams, yet the server observed a brand-new connection whose connectioninit payload was byte-identical to the victim's, including the victim's bearer token. - Partial overwrite: when the later caller supplied only {tenant: "mallory-tenant"}, Object.assign merged rather than replaced, so the victim's Authorization was retained alongside the attacker's tenant. - Calling executorSymbol.dispose between requests did not clear the state. - Negative control: patching the installed 1.1.33 to add executorConnectionParams = {} inside cleanupWebsocket made the second connection's init payload {}, so the leak is causally attributable to the missing reset. - The same proof of concept leaks identically on 1.1.35, and master still contains the unreset closure. Upstream line numbers on master are slightly offset from those cited above (closure declaration L38, conditional Object.assign L100-105, cleanupWebsocket L84, onopen merge L60-63).

Steps:

1. Run a legacy GraphQL WS server that records the connectioninit payload and authorizes notifications by Authorization value. 2. Use one executor for a victim request with extensions.connectionParams.Authorization = 'Bearer victim-token'; finish/unsubscribe it so cleanup closes the socket. 3. Use the same executor for an anonymous request with no extensions.connectionParams. 4. Observe that the second connectioninit still contains Bearer victim-token and that the anonymous subscription receives victim-authorized results.

ts import WebSocket from 'isomorphic-ws'; import { parse } from 'graphql'; import { buildWSLegacyExecutor } from '@graphql-tools/executor-legacy-ws';

const exec = buildWSLegacyExecutor('ws://localhost:8081/graphql', WebSocket);

const v = exec({ document: parse('subscription { privateEvents { id } }'), extensions: { connectionParams: { Authorization: 'Bearer victim-token' } }, }); await v.next(); await v.return?.(); // iterator cleanup closes the first socket; executorConnectionParams is not cleared

const a = exec({ document: parse('subscription { privateEvents { id } }') }); await a.next(); // the server logs a second connectioninit payload still carrying Authorization: Bearer victim-token

- Expected: the second connectioninit payload contains only the base options and the second request's own parameters, with no key omitted by that request carried over from the victim. - Actual: the second connectioninit carries the victim's retained Authorization/tenant values, and the downstream server authenticates the later caller as the victim.

Suggested remediation

Do not retain operation-level extensions.connectionParams beyond the operation and connection they belong to: build each connectioninit payload from the base options plus the current request's parameters only, and clear connection-specific state during cleanup.

ts // 1. Clear the retained state when the connection is torn down. function cleanupWebsocket() { // ... existing connectionterminate / terminate / websocket = null ... executorConnectionParams = {}; }

Alternative, and the more complete form: stop accumulating into shared closure state at all — derive the payload per request, e.g. const payload = { ...options.connectionParams, ...(request.extensions?.['connectionParams'] ?? {}) }, passed down to the onopen handler for that connection rather than read from the executor closure.

Notes:

- executorSymbol.dispose is bound to cleanupWebsocket, so fixing the reset there also makes the documented teardown behave as callers expect. - Object.assign merges rather than replaces, so a later request supplying some parameters still inherits the keys it omits; a fix that only replaces the object on the "present" branch would leave this half-open. - The UrlLoader path in this repository constructs a fresh executor per request and is already safe; no change is needed there. The exposure is to direct consumers of the exported buildWSLegacyExecutor. - It would also help to define explicitly whether mixing distinct auth contexts on one executor is supported, and to reject it if not — today the factory returns a general Executor with no stated per-identity restriction.

Suggested regression test alongside the package's existing legacy-ws executor tests (the exact spec file is not verified by the available information): drive one executor against a recording server, send request 1 with extensions.connectionParams = { Authorization: 'Bearer victim-token' }, let it complete, then send request 2 with no connectionParams, and assert the second connectioninit payload contains no Authorization key. Add a second case where request 2 supplies only { tenant: 'other' } and assert the payload is exactly { tenant: 'other' } plus base options.

Disclosure status

Currently undisclosed: no CVE, GHSA, advisory URL, upstream issue, or fix commit is associated with this finding, and no fixed version exists to upgrade to.

Resolution

Fixed in @graphql-tools/executor-legacy-ws@1.1.36, which is published on npm. Each execution opens its own socket. connectioninit is built from the executor's base connectionParams plus only that request's extensions.connectionParams. Upgrade to >=1.1.36.

Affected Software

1 affected componentFixes available
npm/@graphql-tools/executor-legacy-ws<=1.1.35
1.1.36

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade npm/@graphql-tools/executor-legacy-ws to a version that resolves this vulnerability.

    Fixed in 1.1.36
  2. Upgrade

    Upgrade @graphql-tools/executor-legacy-ws to a version that resolves this vulnerability.

    Fixed in 1.1.36

Event History

Oct 28, 2026
Advisory Published
via GitHub·09:05 PM
Data Sourced
via GitHub·09:05 PM
DescriptionWeaknessAffected Software

Frequently Asked Questions

1

Is a default use of the executor affected?

Yes. The factory returns a general reusable Executor without a per-identity or single-use restriction, so the affected behavior occurs in the default configuration.

2

What conditions are needed for cross-caller credential reuse?

The same executor must be reused across callers. A request that supplies extensions.connectionParams can leave those parameters in closure state, where they can be replayed during a later caller’s connection_init.

3

Which releases were observed to have this behavior?

The behavior was verified in npm release 1.1.33 and was also observed in 1.1.35, described as the current latest release during validation. The unreset closure was also present on master at the time of the report.

4

Can retained connection parameters be cleared through a supported teardown mechanism?

No supported teardown was identified that clears the retained parameters.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203