Skip to content

zone.js: Promise reactions cause cross request AsyncLocalStorage context leakage in SSR #71825

Open

Description

@SkyZeroZx

Which @angular/* package(s) are the source of the bug?

zone.js

Is this a regression?

No

Description

With zone.js loaded on the server, AsyncLocalStorage.getStore() can return the store of a different SSR request.

zone.js replaces the global Promise. In then() it saves Zone.current, but not Node's async context promise

const zone = Zone.current;
if ((this as any)[symbolState] == UNRESOLVED) {
(<any[]>(this as any)[symbolValue]).push(zone, chainPromise, onFulfilled, onRejected);
} else {
scheduleResolveOrReject(this, zone, chainPromise as any, onFulfilled, onRejected);
}

The reactions then go into one queue for the whole process, and the queue is drained from a single native microtask, scheduled by whatever queued first

function nativeScheduleMicroTask(func: Function) {
if (!nativeMicroTaskQueuePromise && global[symbolPromise]) {
nativeMicroTaskQueuePromise = global[symbolPromise].resolve(0);
}
if (nativeMicroTaskQueuePromise) {
const thenFn = nativeMicroTaskQueuePromise[symbolThen] ?? nativeMicroTaskQueuePromise['then']; // fallback for non-patchable Promise
// Use the resolved native promise to schedule the microtask
thenFn.call(nativeMicroTaskQueuePromise, func);
} else {
// Fallback to setTimeout if native promise is unavailable
global[symbolSetTimeout](func, 0);
}
}
function scheduleMicroTask(task?: MicroTask) {
const isNativeDrainingEnabled = global[enableNativeMicrotaskDraining];
const shouldDrainWithNative =
isNativeDrainingEnabled && _microTaskQueue.length === 0 && !_isDrainingMicrotaskQueue;
const shouldDrainWithoutNative =
!isNativeDrainingEnabled && _numberOfNestedTaskFrames === 0 && _microTaskQueue.length === 0;
if (shouldDrainWithNative || shouldDrainWithoutNative) {
// Start draining the microtask queue if:
// - Native draining is enabled and not currently in progress, or
// - Native draining is disabled, and there are no nested tasks and no queued microtasks.
nativeScheduleMicroTask(drainMicroTaskQueueSynchronously);
}
if (task) {
_microTaskQueue.push(task);
}
}
function drainMicroTaskQueueSynchronously() {
if (_isDrainingMicrotaskQueue) {
return;
}
_isDrainingMicrotaskQueue = true;
try {
while (_microTaskQueue.length) {
const queue = _microTaskQueue;
_microTaskQueue = [];
for (const task of queue) {
try {
task.zone.runTask(task, null, null);
} catch (error) {
_api.onUnhandledError(error as Error);
}
}

Node runs that microtask in the async context of the code that scheduled it. So when two requests have reactions in the same drain, all of them run with the first request's store. Zone.current is still right for each task, and inject(REQUEST) was right in every render I measured. What's wrong is Node's async context, which is what AsyncLocalStorage reads.

A lot of server code finds the current request that way: express-http-context, a pino logger with a request context, OpenTelemetry's context manager, Sentry. In my test app an interceptor forwards the user's token from express-http-context, and 20 concurrent renders called the backend with the same user's token.

Code in the app always goes through then(), because with zone.js in polyfills the CLI compiles async/await to generators over the global Promise. In both apps below, the zone.js server bundle has no native await left, while the zoneless build of the same source keeps 70 (Angular 22) and 80 (Angular 21).

node v24.16.0, zone.js 0.16.3

test                          request   Zone.current   als.getStore()  verdict
A same-turn .then()           victim    req-victim     victim          ok
A same-turn .then()           attacker  req-attacker   victim          LEAK
B shared in-flight promise    victim    req-victim     victim          ok
B shared in-flight promise    attacker  req-attacker   victim          LEAK
C still wrong 3 hops later    victim    req-victim     victim          ok
C still wrong 3 hops later    attacker  req-attacker   victim          LEAK
D native await                victim    <root>         victim          ok
D native await                attacker  <root>         attacker        ok

AsyncLocalStorage store from another request: 3/8
Zone.current from another request:            0/8 (2 are <root> after native await)

With 0.16.3 it's the same on Node 20.19.5, 22.23.4, 24.16.0 and 26.13.0,

Two renders have to be waiting on the same pending work.

what both renders wait on what the app needs first requests of a process after warm-up
1 the SSR manifest's import() of main.server.mjs and the index HTML nothing yes no
2 the router's loadComponent a lazy route that reads the identity yes no
3 the chunk of a @defer block with a hydrate trigger that block, with incremental hydration on yes no
4 every import(), once an async ESM loader hook is registered @sentry/node on Node 22 yes yes
5 a request shared through a useValue provider that provider yes yes

In all five, the same app without zone.js is correct. The other pieces only decide when two renders end up in the same drain.

Angular 22.2.4, on the Node versions it supports. /account, 20 concurrent renders with their own session cookie, first requests of a new process. Distinct bearer tokens the backend received:

Node 22.23.4 24.22.2 26.13.0
@sentry/node 11.10.0 with --import, no cache (4) 1 20 20
useValue cache (5) 1 1 1
neither 20 20 20
zoneless, with Sentry or with the cache 20 20 20

The Sentry row has no cache and no shared state in the app, and it gives the same result with the manifest's imports loaded before the server starts listening. On Linux with Node 22.22.3, which is Angular 22's minimum, it's also 1 token for 20 renders. It keeps happening after warm-up: over three runs on 22.23.4 the backend got 14 to 16 distinct tokens for 100 renders, and 84 to 86 log lines had the wrong user.

Sentry uses the synchronous module.registerHooks() only on Node 24.13+ and 25.1+ (@sentry/server-runtime-injection/build/cjs/register.js:28-34). On older versions it registers two asynchronous module.register() hooks, and @angular/ssr imports the index HTML on every render (ssr.mjs:1172), so there is always an import() that two renders can wait on.

The cache in row 5 holds public settings and is shared on purpose: one HttpClient call, shareReplay, and await firstValueFrom(...) in each render. What crosses is the identity, not the cached value. After warm-up it was 69 tokens for 100 renders on 22.23.4 and 81 on 26.13.0.

The application doesn't have to use AsyncLocalStorage itself. I removed all the identity code and kept only OpenTelemetry's zero-code agent and the cache, and the backend received 1 trace id for 20 renders, on 22.23.4 and on 26.13.0. OpenTelemetry's context manager reads the store in active() (AsyncLocalStorageContextManager.cjs:41).

Cases 1 to 3 are inside Angular, and they need Node older than 22.18.0. Angular 21.2.26 on Node 22.17.1, 3 new processes with 20 concurrent requests each, renders that read another request's identity:

route zone.js zoneless
/account, identity read in a resolver (1) 57/60 0/60
/profile, lazy, identity read in the component (2) 57/60 0/60
/ and /settings, @defer (hydrate on …) panel (3) 30/33 0/33

Same numbers on 20.19.5 and 22.16.0. On /account without the cache, the backend received 1 distinct token for 20 renders and pino logged 19 of 20 lines under the wrong user. These three happen in the first requests of a process, after a deploy or a restart.

They stop at 22.18.0 because a new request arrives in an I/O callback, so it can only join an import() that is still pending after a turn of the event loop. One cold chunk import spans 11 to 18 turns on 22.17.1 and none on 22.18.0, the release with nodejs/node#57419 (esm: syncify default path of ModuleLoader.load). That covers Angular 21 on Node 20 and on Node 22 before 22.18.0.

Please provide a link to a minimal reproduction of the bug

https://github.com/SkyZeroZx/zone-als-leak

Expected behavior

A reaction runs in the async context where then() was called, as it does with native promises, so each request's AsyncLocalStorage.getStore() returns its own store. Node's docs ask the same of a custom thenable implementation async_context, Troubleshooting: Context loss.

Anything else?

It also works the other way around. If the attacker's request is the one that starts the shared fetch, the other renders in that burst run as the attacker: they load the attacker's account with the attacker's token, and whatever they write is recorded as the attacker.

This reminds me in part of GHSA-68x2-mx4q-78m7, where concurrent SSR requests could also end up with another request's data. There it was the global platform injector; here it is Node's async context.

Activity

  1. added this to the needsTriage milestone on Nov 5, 2026
  2. added this to the needsTriage milestone on Nov 5, 2026
  3. added a commit that references this issue on Nov 5, 2026
    0e36a87
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: zonesIssues related to zone.js

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions