Vulnerability DatabaseGHSA-xrjv-fc7j-8f77

GHSA-xrjv-fc7j-8f77: 
JavaScript vulnerability analysis and mitigation

Report metadata

FieldValue
Package@graphql-tools/batch-execute (npm, pkg:npm/%40graphql-tools/batch-execute)
Repositoryhttps://github.com/graphql-hive/gateway
ComponentmergeRequests (packages/batch-execute/src/mergeRequests.ts), reached from createBatchingExecutor(executor)
Vulnerability classAuthorization bypass (cross-request execution-context confusion)
CWECWE-863
Severity (reporter assessment)High
Verified againstCommit a0d7bd84f2c655b67410831ae8119bc26c351cc4; source read at tag @graphql-tools/batch-execute@10.0.9; the same proof-of-concept also reproduces unchanged on 10.2.0
API surfacePublic documented API — createBatchingExecutor is exported from packages/batch-execute/src/index.ts:1
Affected in default configurationYes — the default is one queryLoader and one mutationLoader per returned executor, with no keying or partitioning by context (packages/batch-execute/src/createBatchingExecutor.ts:25-34)
Affected versions<= 10.0.9 was the originally recorded range; the same proof-of-concept reproduces unchanged on 10.2.0, so that range is understated. Oldest affected version: not verified
Existing CVE / advisoryNone known — no CVE, GHSA, or advisory reference available

Summary

createBatchingExecutor returns a single Executor backed by one query DataLoader and one mutation DataLoader. When more than one ExecutionRequest is queued in the same dispatch tick, mergeRequests concatenates the selections of every request into one merged operation, but it carries only the first non-null execution metadata across it: context ??= request.context, info ??= request.info, rootValue ??= request.rootValue. The merged operation is then handed to the underlying executor, and splitResult de-prefixes the single result back to each original caller by index. The consequence is that if a consuming application holds one createBatchingExecutor(...) instance and reuses it across users (for example, creating it once at server initialization and calling it from each HTTP handler with per-user authorization in request.context), a request queued behind another user's request has its own fields resolved with the other user's authorization context — and receives those results. Nothing in the batching path compares or groups requests by context, rootValue, or info. The surrounding code shows that the safe shape is already known in the project; the publicly exported factory is the path that does not have it:

  • getBatchingExecutor(context, ...) used by packages/delegate/src/delegateToSchema.ts:341-346 → scoped by a caller-supplied context key, does not leak
  • getBatchingExecutor(context, ...) used by packages/fusion-runtime/src/utils.ts:257-260 → scoped by a caller-supplied context key, does not leak
  • createBatchingExecutor(executor) → unscoped; first non-null context wins for the whole batch, packages/batch-execute/src/mergeRequests.ts:83-109

Impact

A concurrent attacker receives data, or has operations performed, under another user's or tenant's authorization. In the verified reproduction, the attacker's own request resolved an admin-only field and returned the victim's admin secret with no authorization error raised — the authorization check in the resolver never ran against the attacker's identity, because the underlying executor only ever saw the victim's context. The reverse-order control shows the same mechanism can also strip a legitimate user of their own privileges: an admin victim queued second was denied her own admin field and her whoami resolved as the other user. The delta versus correct behaviour: without batching (or with a per-request executor instance), the underlying executor receives the caller's own context and the authorization check runs against the caller. With a shared batching executor, the batch — not the caller — selects the identity under which the caller's selections execute. This is not an intended consequence of batching: batching is meant to preserve the results of separate ExecutionRequests while optimizing transport, and the returned type is a generic Executor whose request context is part of execution semantics. Scoring note: no CVSS vector is asserted here. Exploitability depends on the consuming application's data flow — specifically on whether a single executor instance is shared across principals and whether requests from different principals can co-schedule inside one DataLoader batch window. The reporter assessment is High for consumers matching that shape. Who is not affected: the package's own internal consumers. packages/delegate/src/delegateToSchema.ts:341-346 and the fusion-runtime executor path use getBatchingExecutor, which is memoized on context; in the same test run those paths demonstrably did not leak. Hive Gateway and stitching are therefore not themselves exposed by this. The exposure is to downstream npm consumers of the separately exported createBatchingExecutor, held as a shared or global instance.

Preconditions

  1. The consumer constructs one createBatchingExecutor(...) instance (for example at server initialization) and reuses it across security principals.
  2. Authorization-relevant state is carried in ExecutionRequest.context (or rootValue / info) rather than being bound into a per-principal executor.
  3. The attacker can cause a query or mutation to be queued in the same DataLoader dispatch tick as a victim request of the same operation type — queries and mutations are the only partition, at packages/batch-execute/src/createBatchingExecutor.ts:25-34.
  4. The victim's request is the first request with a non-null context in that batch; first-context-wins was confirmed by running the two requests in both orders. A measured nuance on precondition 3, from the dynamic reproduction: under Node's default HTTP server, independent requests each land in their own event-loop tick, so 20, 100 and 400 concurrent plain requests — and HTTP/2 multiplexing and HTTP/1.1 pipelining — produced maxBatch=1 and zero leaks. The merge requires the handlers to co-schedule inside one DataLoader batch window, which any shared pending promise produces: with requests parked on a single in-flight dependency (a JWKS, session, or supergraph fetch deduped while pending — a standard gateway pattern) all 20 resumed in one microtask drain and merged. The flaw is fully exploitable, but conditional on that consumer shape rather than firing on every concurrent pair. This requires no non-standard usage, monkey-patching, or unusual configuration: it is the default scheduling behaviour of the exported factory. packages/batch-execute/README.md links the API and batch-delegation docs and contains no warning that createBatchingExecutor must be scoped per request or per context, and the trust requirement is not documented as a caller responsibility. packages/batch-execute/tests/batchExecute.test.ts:65 creates one wrapper for multiple requests, and :343-368 explicitly demonstrates first-context inheritance.

<!---

Data flow

  1. Source — untrusted, per-principal authorization data enters as ExecutionRequest.context on concurrent calls to the returned batching executor; both requests are loaded into one operation-type DataLoader (packages/batch-execute/src/createBatchingExecutor.ts:27-34).
  2. Propagation — the DataLoader batch function passes the entire concurrent requests array to mergeRequests; the only partition applied anywhere is query versus mutation (packages/batch-execute/src/createBatchingExecutor.ts:50-64).
  3. Sink — mergeRequests merges every request's selections into one document but selects the execution metadata once: context ??= request.context (likewise info and rootValue), and returns them on the merged request (packages/batch-execute/src/mergeRequests.ts:83-109, returned at :136-149).
  4. Execution — the merged request, containing the attacker's fields and the victim's context, is sent to the underlying executor (packages/batch-execute/src/createBatchingExecutor.ts:61-64).
  5. Reachability — splitResult returns each prefixed field result to its original caller by index, so the attacker receives their own field as resolved under the victim's context (packages/batch-execute/src/splitResult.ts:15-40). The entry point is the public API createBatchingExecutor(executor) returning Executor(request), exported from packages/batch-execute/src/index.ts:1; the vulnerable mergeRequests path is taken for any batch of length > 1 at createBatchingExecutor.ts:60.

Vulnerable code

packages/batch-execute/src/mergeRequests.ts, lines 83-149 at a0d7bd84f2c655b67410831ae8119bc26c351cc4:

for (let index = 0; index < requestCount; index++) {
  const request = requests[index];
  if (request) {
    ...
    context ??= request.context;      // <-- sink: first non-null context wins for the whole batch
    info ??= request.info;            // <-- same issue
    rootValue ??= request.rootValue;  // <-- same issue
    const prefixedRequests = prefixRequest(createPrefix(index), request);
    for (const def of prefixedRequests.document.definitions) {
      if (isOperationDefinition(def)) {
        mergedSelections.push(...def.selectionSet.selections);   // every request's selections are merged
      }
    }
  }
}
...
return { ..., document: { ... }, context, info, rootValue, ... };  // <-- merged selections shipped under one request's context

Reproduction

Verified. Reproduced dynamically and end-to-end against @graphql-tools/batch-execute@10.0.9 with graphql@16 on Node v24.15.0, using a schema with an authorization-sensitive resolver reading ctx.user. The TypeScript source at tag @graphql-tools/batch-execute@10.0.9 matches the cited line ranges verbatim. Two observations were made:

  1. In-process. A single createBatchingExecutor instance received a victim request (alice, admin) and an attacker request (mallory, guest) concurrently. Both collapsed into one underlying execution, { _v0_whoami: whoami _v1_adminSecret: adminSecret }, carrying only alice's context. Mallory received alice's admin-only secret (ALICE-ADMIN-API-KEY-7f3c9) with no authorization error — the Forbidden check never ran for her. Controls confirm the authorization gate works otherwise: mallory alone is denied, and per-request executor instances do not leak. Running the requests in the reverse order confirmed first-queued-context-wins (an admin victim was denied her own admin field, and her whoami resolved as mallory).
  2. Over HTTP. With a module-global executor behind a real HTTP server, 20 concurrent authenticated requests (alternating alice and mallory) merged into a single subgraph execution under alice's context, and all 10 of mallory's HTTP responses returned alice's identity and private balance. As noted under Preconditions, the merge occurred once the handlers co-scheduled behind a single shared in-flight dependency; plain independent requests at 20/100/400 concurrency did not merge. Minimal proof-of-concept:
import { createBatchingExecutor } from '@graphql-tools/batch-execute';
import { parse } from 'graphql';
const execute = createBatchingExecutor(async req => {
  const data = {};
  for (const field of req.document.definitions[0].selectionSet.selections) {
    data[field.alias?.value ?? field.name.value] = req.context.user.secret;
  }
  return { data };
});
const [victim, attacker] = await Promise.all([
  execute({ document: parse('{ secret }'), context: { user: { secret: 'victim-secret' } } }),
  execute({ document: parse('{ secret }'), context: { user: { secret: 'attacker-secret' } } }),
]);
console.log({ victim: victim.data.secret, attacker: attacker.data.secret });
// vulnerable: { victim: 'victim-secret', attacker: 'victim-secret' }
// expected:   { victim: 'victim-secret', attacker: 'attacker-secret' }

Steps:

  1. Install @graphql-tools/batch-execute and graphql.
  2. Run the snippet above with Node.
  3. Observe that the second result's secret is victim-secret rather than attacker-secret, because both selections were executed in one merged request under the first context.
  • Expected: each ExecutionRequest executes under its own context, or differently scoped requests are not batched together.
  • Actual: both requests' selections execute in one merged request under the first non-null context, and each caller receives its slice of that result — so the second caller's fields are resolved with the first caller's authorization.

Suggested remediation

Do not merge requests that carry different execution contexts: either partition the DataLoader instances by context/rootValue/security-relevant metadata (as getBatchingExecutor already does), or reject a batch whose requests do not share the same context identity. Minimal, defensive form — fail closed in mergeRequests rather than silently picking one context:

// packages/batch-execute/src/mergeRequests.ts
if (context === undefined) {
  context = request.context;
} else if (request.context !== context) {
  throw new Error(
    'Cannot batch ExecutionRequests with different contexts; ' +
      'scope the batching executor to a single execution context.',
  );
}

Alternative form, matching the project's existing idiom — key the loaders by context inside createBatchingExecutor, the way getBatchingExecutor memoizes on the context argument, so requests with distinct contexts land in distinct DataLoaders and are never merged. Notes:

  • info and rootValue are selected by the same ??= pattern on the adjacent lines and carry the same cross-request confusion; the fix should cover all three.
  • getBatchingExecutor, used by packages/delegate/src/delegateToSchema.ts:341-346 and packages/fusion-runtime/src/utils.ts:257-260, is keyed on context and did not leak under the same test — it needs no change, which narrows the work to the createBatchingExecutor export.
  • Independently of the code change, packages/batch-execute/README.md does not state that createBatchingExecutor must be confined to one security context; documenting and enforcing that precondition would close the gap for existing consumers.
  • packages/batch-execute/tests/batchExecute.test.ts:343-368 currently encodes first-context inheritance as expected behaviour and would need to be revisited alongside the fix. Suggested regression test alongside packages/batch-execute/tests/batchExecute.test.ts: issue two same-tick requests through one executor with distinct contexts ({ user: { secret: 'victim-secret' } } and { user: { secret: 'attacker-secret' } }) against an executor that echoes req.context.user.secret, and assert the second caller receives attacker-secret — or that the mixed-context batch is rejected — never victim-secret. --->

Source: NVD

Related JavaScript vulnerabilities:

CVE ID

Severity

Score

Technologies

Component name

CISA KEV exploit

Has fix

Published date

GHSA-rhhp-7488-3qwfMEDIUM6.5
  • JavaScript logoJavaScript
  • nitropack
NoYesNov 02, 2026
GHSA-pqxc-r4h5-jc95MEDIUM6.1
  • JavaScript logoJavaScript
  • nitropack
NoYesNov 02, 2026
GHSA-39r7-wfcx-99xgMEDIUM4.8
  • JavaScript logoJavaScript
  • nitropack
NoYesNov 02, 2026
GHSA-xrjv-fc7j-8f77HIGHN/A
  • JavaScript logoJavaScript
  • @graphql-tools/batch-execute
NoNoNov 02, 2026
GHSA-7p5p-67m9-5c8jHIGHN/A
  • JavaScript logoJavaScript
  • @graphql-tools/executor-http
NoNoNov 02, 2026

Free Vulnerability Assessment

Benchmark your Cloud Security Posture

Evaluate your cloud security practices across 9 security domains to benchmark your risk level and identify gaps in your defenses.

Request assessment

Additional Wiz resources

Get a personalized demo

Ready to see Wiz in action?

"Best User Experience I have ever seen, provides full visibility to cloud workloads."
David EstlickCISO
"Wiz provides a single pane of glass to see what is going on in our cloud environments."
Adam FletcherChief Security Officer
"We know that if Wiz identifies something as critical, it actually is."
Greg PoniatowskiHead of Threat and Vulnerability Management