Summary
provideNextAfterProvider() captures one request's waitUntil and publishes it to
globalThis[Symbol.for("@next/request-context")], which is isolate-global and never cleared. Every
request overwrites it; nothing removes it. On a runtime where one isolate serves concurrent requests
(Cloudflare Workers via @opennextjs/cloudflare), the global routinely holds a different, still-live
request's capability while another request is in flight.
I want to be precise about severity: I could not make this exploitable, and I am not reporting it as the
cause of any incident. Under realistic concurrency the exposure window measured as effectively
zero-width (evidence below). I'm filing it as a latent correctness defect — the value is
request-scoped, its storage is isolate-scoped, and today only an accident of timing keeps that from
mattering. The fix is small and preemptive.
Affected: @opennextjs/aws v4.0.1 (current at time of writing); the code is long-standing.
Most consequential on @opennextjs/cloudflare, where waitUntil is a ctx-bound capability that
throws if used from another request's I/O context.
The code
packages/open-next/src/utils/promise.ts — provideNextAfterProvider() (L73):
const store = globalThis.__openNextAls.getStore(); // (1) resolved ONCE, at request start
const waitUntil =
store?.waitUntil ??
((promise: Promise<unknown>) => store?.pendingPromiseRunner.add(promise));
const nextAfterContext = {
get: () => ({
waitUntil, // (2) closes over the CAPTURED value
}),
};
//@ts-expect-error
globalThis[NEXT_REQUEST_CONTEXT_SYMBOL] = nextAfterContext; // (3) isolate scope, never cleared
The get() indirection looks like late binding, but getStore() has already run and waitUntil is
already a constant by the time get is defined. The object written to globalThis therefore hard-binds
one specific request.
Mechanism
-
A request-bound capability is created. On Cloudflare,
packages/open-next/src/overrides/wrappers/cloudflare-node.ts (L97-99) passes
waitUntil: ctx.waitUntil.bind(ctx) — bound to that request's ExecutionContext. (Same in
cloudflare-edge.ts for the middleware bundle.) In workerd, using such an object from a different
request's I/O context throws Cannot perform I/O on behalf of a different request.
-
It is published to isolate-global state. provideNextAfterProvider() is called unconditionally
from runWithOpenNextRequestContext (src/utils/promise.ts L96-126), which runs on both bundles —
src/core/requestHandler.ts and src/adapters/middleware.ts — so each HTTP request writes this global
twice. There is no corresponding delete or restore anywhere in the package.
-
Next.js reads it back per-request. getBuiltinRequestContext()
(next/dist/server/after/builtin-request-context.js) reads the symbol and calls ctx.get(); that
feeds BaseServer.getWaitUntil(), which is evaluated on the App Router path where base-server
invokes ComponentMod.handler({ waitUntil: this.getWaitUntil() }) (base-server.js:1463 in
next@16.2.6). The value is then invoked when the request has pending revalidates
(app-render.js:1449 / :1561).
So a request can, in principle, obtain and invoke another request's waitUntil.
Why it is not currently exploitable — and why I'm still filing
I built an instrument that intercepts the symbol with a get/set accessor, tags each write with
OpenNext's own __openNextAls requestId, and compares reader vs writer at read time. (requestId is
shared across the middleware and server bundles via INTERNAL_EVENT_REQUEST_ID, so a mismatch means
genuinely different HTTP requests, not the two-bundle topology.) Run on a dedicated Cloudflare Worker:
The clobbering is real. A request held open 9s saw the global's owner change to a different
concurrent request's id, on the same isolate, with the clobbering request positively identified.
But the read is never foreign:
| run |
shape |
reads |
foreign reads |
| 1 |
trivial route-handler victim, 56 co-resident requests |
218 |
0 |
| 2 |
cold heavy page renders (100), 240 concurrent requests, 26 isolates |
680 |
0 |
Exposure window: 0 ms on 680 of 680 reads.
The reason is step 3: Next.js reads the global early — at handler entry — and holds the value for the
rest of the request. The vulnerable span is therefore own-write → own-read, not the request duration,
and that span contains no I/O, so no other request can interleave into it. There's also a structural
catch: the span plausibly does open on a cold isolate (route-module await import()), but a cold
isolate's first request is by definition alone on that isolate, with no neighbour to do the clobbering.
Why fix it anyway: the defect is one await away from mattering. Any future change that introduces
I/O between the publish and the read — in OpenNext's routing layer or in Next.js's path to
getWaitUntil() — opens the window silently, and the resulting failure would be a rare, timing-dependent
cross-request error that is extremely hard to diagnose. Storing a request-scoped capability in
isolate-global state is also just incorrect on its face. The fix is a few lines and costs nothing.
Proposed fix
Resolve the store inside get(), so ALS is consulted per call:
- const store = globalThis.__openNextAls.getStore();
-
- const waitUntil =
- store?.waitUntil ??
- ((promise: Promise<unknown>) => store?.pendingPromiseRunner.add(promise));
-
const nextAfterContext = {
- get: () => ({
- waitUntil,
- }),
+ get: () => {
+ const store = globalThis.__openNextAls.getStore();
+ return {
+ waitUntil:
+ store?.waitUntil ??
+ ((promise: Promise<unknown>) =>
+ store?.pendingPromiseRunner.add(promise)),
+ };
+ },
};
Next.js calls ctx.get() from inside the owning request's async context (getWaitUntil() runs within
runWithOpenNextRequestContext), so ALS resolves the correct request every time.
A pleasant side effect: the published object becomes request-independent, so it no longer needs
rewriting on every request (twice) — it could be installed once per isolate.
Repro method (available on request)
Happy to share the instrument if useful: a get/set accessor over
globalThis[Symbol.for("@next/request-context")], installed from open-next.config.ts (imported by both
bundles during isolate module-graph evaluation, so it precedes every write), logging reader/writer
requestIds plus the write→read window. It reproduces the clobbering deterministically. It did not
reproduce a foreign read, which is the point of the severity scoping above.
What I am explicitly not claiming
I am not claiming this causes any observed production error. I investigated it as a candidate explanation
for a Cannot perform I/O on behalf of a different request incident on our deployment and the evidence
did not support it — hence the latent framing. Treating this as an incident cause would be a
mis-triage; it's a correctness fix, not a hotfix.
Summary
provideNextAfterProvider()captures one request'swaitUntiland publishes it toglobalThis[Symbol.for("@next/request-context")], which is isolate-global and never cleared. Everyrequest overwrites it; nothing removes it. On a runtime where one isolate serves concurrent requests
(Cloudflare Workers via
@opennextjs/cloudflare), the global routinely holds a different, still-liverequest's capability while another request is in flight.
I want to be precise about severity: I could not make this exploitable, and I am not reporting it as the
cause of any incident. Under realistic concurrency the exposure window measured as effectively
zero-width (evidence below). I'm filing it as a latent correctness defect — the value is
request-scoped, its storage is isolate-scoped, and today only an accident of timing keeps that from
mattering. The fix is small and preemptive.
Affected:
@opennextjs/awsv4.0.1 (current at time of writing); the code is long-standing.Most consequential on
@opennextjs/cloudflare, wherewaitUntilis actx-bound capability thatthrows if used from another request's I/O context.
The code
packages/open-next/src/utils/promise.ts—provideNextAfterProvider()(L73):The
get()indirection looks like late binding, butgetStore()has already run andwaitUntilisalready a constant by the time
getis defined. The object written toglobalThistherefore hard-bindsone specific request.
Mechanism
A request-bound capability is created. On Cloudflare,
packages/open-next/src/overrides/wrappers/cloudflare-node.ts(L97-99) passeswaitUntil: ctx.waitUntil.bind(ctx)— bound to that request'sExecutionContext. (Same incloudflare-edge.tsfor the middleware bundle.) In workerd, using such an object from a differentrequest's I/O context throws
Cannot perform I/O on behalf of a different request.It is published to isolate-global state.
provideNextAfterProvider()is called unconditionallyfrom
runWithOpenNextRequestContext(src/utils/promise.tsL96-126), which runs on both bundles —src/core/requestHandler.tsandsrc/adapters/middleware.ts— so each HTTP request writes this globaltwice. There is no corresponding delete or restore anywhere in the package.
Next.js reads it back per-request.
getBuiltinRequestContext()(
next/dist/server/after/builtin-request-context.js) reads the symbol and callsctx.get(); thatfeeds
BaseServer.getWaitUntil(), which is evaluated on the App Router path wherebase-serverinvokes
ComponentMod.handler({ waitUntil: this.getWaitUntil() })(base-server.js:1463innext@16.2.6). The value is then invoked when the request has pending revalidates(
app-render.js:1449/:1561).So a request can, in principle, obtain and invoke another request's
waitUntil.Why it is not currently exploitable — and why I'm still filing
I built an instrument that intercepts the symbol with a
get/setaccessor, tags each write withOpenNext's own
__openNextAlsrequestId, and compares reader vs writer at read time. (requestIdisshared across the middleware and server bundles via
INTERNAL_EVENT_REQUEST_ID, so a mismatch meansgenuinely different HTTP requests, not the two-bundle topology.) Run on a dedicated Cloudflare Worker:
The clobbering is real. A request held open 9s saw the global's owner change to a different
concurrent request's id, on the same isolate, with the clobbering request positively identified.
But the read is never foreign:
Exposure window: 0 ms on 680 of 680 reads.
The reason is step 3: Next.js reads the global early — at handler entry — and holds the value for the
rest of the request. The vulnerable span is therefore own-write → own-read, not the request duration,
and that span contains no I/O, so no other request can interleave into it. There's also a structural
catch: the span plausibly does open on a cold isolate (route-module
await import()), but a coldisolate's first request is by definition alone on that isolate, with no neighbour to do the clobbering.
Why fix it anyway: the defect is one
awaitaway from mattering. Any future change that introducesI/O between the publish and the read — in OpenNext's routing layer or in Next.js's path to
getWaitUntil()— opens the window silently, and the resulting failure would be a rare, timing-dependentcross-request error that is extremely hard to diagnose. Storing a request-scoped capability in
isolate-global state is also just incorrect on its face. The fix is a few lines and costs nothing.
Proposed fix
Resolve the store inside
get(), so ALS is consulted per call:Next.js calls
ctx.get()from inside the owning request's async context (getWaitUntil()runs withinrunWithOpenNextRequestContext), so ALS resolves the correct request every time.A pleasant side effect: the published object becomes request-independent, so it no longer needs
rewriting on every request (twice) — it could be installed once per isolate.
Repro method (available on request)
Happy to share the instrument if useful: a
get/setaccessor overglobalThis[Symbol.for("@next/request-context")], installed fromopen-next.config.ts(imported by bothbundles during isolate module-graph evaluation, so it precedes every write), logging
reader/writerrequestIds plus the write→read window. It reproduces the clobbering deterministically. It did not
reproduce a foreign read, which is the point of the severity scoping above.
What I am explicitly not claiming
I am not claiming this causes any observed production error. I investigated it as a candidate explanation
for a
Cannot perform I/O on behalf of a different requestincident on our deployment and the evidencedid not support it — hence the latent framing. Treating this as an incident cause would be a
mis-triage; it's a correctness fix, not a hotfix.