
Cloud Vulnerability DB
Eine von der Community geführte Datenbank für Schwachstellen
| Field | Value |
|---|---|
| Package | @graphql-tools/batch-execute (npm, pkg:npm/%40graphql-tools/batch-execute) |
| Repository | https://github.com/graphql-hive/gateway |
| Component | mergeRequests (packages/batch-execute/src/mergeRequests.ts), reached from createBatchingExecutor(executor) |
| Vulnerability class | Authorization bypass (cross-request execution-context confusion) |
| CWE | CWE-863 |
| Severity (reporter assessment) | High |
| Verified against | Commit 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 surface | Public documented API — createBatchingExecutor is exported from packages/batch-execute/src/index.ts:1 |
| Affected in default configuration | Yes — 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 / advisory | None known — no CVE, GHSA, or advisory reference available |
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 leakgetBatchingExecutor(context, ...) used by packages/fusion-runtime/src/utils.ts:257-260 → scoped by a caller-supplied context key, does not leakcreateBatchingExecutor(executor) → unscoped; first non-null context wins for the whole batch, packages/batch-execute/src/mergeRequests.ts:83-109A 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.
createBatchingExecutor(...) instance (for example at server initialization) and reuses it across security principals.ExecutionRequest.context (or rootValue / info) rather than being bound into a per-principal executor.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.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.<!---
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).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).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).packages/batch-execute/src/createBatchingExecutor.ts:61-64).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.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 contextVerified. 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:
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).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:
@graphql-tools/batch-execute and graphql.secret is victim-secret rather than attacker-secret, because both selections were executed in one merged request under the first context.ExecutionRequest executes under its own context, or differently scoped requests are not batched together.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.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.
--->Quelle: NVD
Kostenlose Verwundbarkeitsbewertung
Bewerten Sie Ihre Cloudsicherheitspraktiken in 9 Sicherheitsbereichen, um Ihr Risikoniveau zu bewerten und Schwachstellen in Ihren Verteidigungen zu identifizieren.
Eine personalisierte Demo anfordern
"Die beste Benutzererfahrung, die ich je gesehen habe, bietet vollständige Transparenz für Cloud-Workloads."
"„Wiz bietet eine zentrale Oberfläche, um zu sehen, was in unseren Cloud-Umgebungen vor sich geht.“ "
"„Wir wissen, dass, wenn Wiz etwas als kritisch identifiziert, es auch wirklich kritisch ist.“"