Skip to content

provideNextAfterProvider publishes a request-scoped waitUntil to isolate-global state and never clears it (latent cross-request leak) #1205

Description

@as-kul

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.tsprovideNextAfterProvider() (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

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

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

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions