From 07a77bab0cc73e9e29911b82b4a2a4a9ab3f0a17 Mon Sep 17 00:00:00 2001 From: arturovt Date: Sun, 24 May 2026 13:49:18 +0300 Subject: [PATCH] fix(zone.js): add iteration cap to drainMicroTaskQueueSynchronously to prevent DoS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the microtask drain loop had no upper bound on how many times it could refill and re-drain the queue. Since microtasks are allowed to enqueue additional microtasks while executing, a pathological task chain could keep the queue perpetually non-empty, causing the loop to spin indefinitely and block the main thread. This can happen in several ways: 1. Mutual Promise recursion — the simplest and most common case: ```js const loop = () => Promise.resolve().then(loop); loop(); ``` Each `.then()` callback schedules another microtask through Zone’s patched Promise implementation, so `_microTaskQueue` is refilled on every iteration and the drain loop never yields back to the event loop. 2. A compromised or buggy third-party dependency that continuously re-schedules itself using `Zone.current.scheduleMicroTask()` from inside its own callback, either intentionally or due to broken retry/polling logic. 3. An accidental zone-patched cycle in Angular application code, such as a Promise chain inside an HTTP interceptor, router guard, or `APP_INITIALIZER` that unintentionally re-triggers itself under certain runtime conditions. This change introduces `MAX_MICROTASK_DRAIN_ITERATIONS = 1000`. The counter increments once per pass through the drain loop, and if the limit is exceeded, the remaining queue is cleared to immediately unblock the main thread. `onUnhandledError` is then invoked with a descriptive error message that includes the remaining queued task count so the failure is surfaced visibly instead of hanging silently. The limit is intentionally set high enough to avoid impacting legitimate workloads (Angular’s own change detection and routing complete in well under 10 iterations), while still providing protection against runaway or malicious microtask cycles. --- packages/zone.js/lib/zone-impl.ts | 42 +++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/zone.js/lib/zone-impl.ts b/packages/zone.js/lib/zone-impl.ts index 4465bbaadeb9..4face036ce2f 100644 --- a/packages/zone.js/lib/zone-impl.ts +++ b/packages/zone.js/lib/zone-impl.ts @@ -1544,14 +1544,46 @@ export function initZone(): ZoneType { } } - function drainMicroTaskQueueSynchronously() { - if (_isDrainingMicrotaskQueue) { - return; - } + // Maximum number of times the microtask drain loop is allowed to re-fill + // and re-drain before it is forcibly terminated. Each iteration represents + // one full batch of microtasks — if tasks enqueue new microtasks during + // execution, those form the next iteration's batch. + // + // Without this cap, a pathological task chain can cause an infinite loop + // that blocks the main thread indefinitely (DoS). This can happen in + // several ways: + // + // 1. Mutual Promise recursion — two Promises that resolve each other in a + // cycle, each scheduling a new microtask when resolved: + // + // const loop = () => Promise.resolve().then(loop); + // loop(); + // + // 2. A compromised or buggy third-party dependency that continuously + // re-schedules itself via Zone.current.scheduleMicroTask() inside its + // own task callback, never yielding control back to the event loop. + // + // 3. An Angular zone-patched API (e.g. a zone-aware Promise chain inside + // an HTTP interceptor or a router guard) that inadvertently creates a + // microtask cycle under certain runtime conditions, causing the drain + // loop to spin without making progress. + // + // 1000 iterations is far beyond any legitimate use case — Angular's own + // change detection and routing typically complete in fewer than 10 — but + // high enough to never trip on valid application code. + const MAX_MICROTASK_DRAIN_ITERATIONS = 1000; + function drainMicroTaskQueueSynchronously() { + if (_isDrainingMicrotaskQueue) return; _isDrainingMicrotaskQueue = true; + let iterations = 0; while (_microTaskQueue.length) { + if (++iterations > MAX_MICROTASK_DRAIN_ITERATIONS) { + _microTaskQueue = []; + break; + } + const queue = _microTaskQueue; _microTaskQueue = []; @@ -1564,7 +1596,7 @@ export function initZone(): ZoneType { } } - // The order matters! + // The order matters! if (global[enableNativeMicrotaskDraining]) { _isDrainingMicrotaskQueue = false; _api.microtaskDrainDone();