src: rethrow stack overflow exceptions in async_hooks When a stack overflow exception...
authorMatteo Collina <hello@matteocollina.com>
Sun, 26 Apr 2026 15:21:57 +0000 (17:21 +0200)
committerBastien Roucariès <rouca@debian.org>
Mon, 6 Apr 2026 14:18:52 +0000 (16:18 +0200)
The implementation adds IsStackOverflowError() helper to detect stack
overflow RangeErrors and re-throws them in TryCatchScope destructor
instead of calling FatalException.

This fixes the issue where async_hooks would cause stack overflow
exceptions to exit with code 7 (kExceptionInFatalExceptionHandler)
instead of being catchable.

Fixes: #37989
Ref: https://hackerone.com/reports/3456295
PR-URL: nodejs-private/node-private#773
Refs: https://hackerone.com/reports/3456295
Reviewed-By: Robert Nagy <ronagy@icloud.com>
Reviewed-By: Paolo Insogna <paolo@cowtech.it>
Reviewed-By: Marco Ippolito <marcoippolito54@gmail.com>
Reviewed-By: Rafael Gonzaga <rafael.nunu@hotmail.com>
Reviewed-By: Anna Henningsen <anna@addaleax.net>
CVE-ID: CVE-2025-59466
origin: backport, https://github.com/nodejs/node/commit/d7a5c587c02ebe18f9fe4de986bac55d80c2868f
bug: https://nodejs.org/en/blog/vulnerability/december-2025-security-releases#uncatchable-maximum-call-stack-size-exceeded-error-on-nodejs-via-async_hooks-leads-to-process-crashes-bypassing-error-handlers-cve-2025-59466---medium

Gbp-Pq: Name CVE-2025-59466.patch

src/async_wrap.cc
src/node_errors.cc
src/node_errors.h
test/parallel/test-async-hooks-stack-overflow-nested-async.js [new file with mode: 0644]
test/parallel/test-async-hooks-stack-overflow-try-catch.js [new file with mode: 0644]
test/parallel/test-async-hooks-stack-overflow.js [new file with mode: 0644]
test/parallel/test-uncaught-exception-handler-stack-overflow-on-stack-overflow.js [new file with mode: 0644]
test/parallel/test-uncaught-exception-handler-stack-overflow.js [new file with mode: 0644]

index 697eff06c9f3cbaba3b2288628f76e43b85df0cb..44150e4a7b9b6bc71b88c359ff13944b84c8df3e 100644 (file)
@@ -66,7 +66,8 @@ static const char* const provider_names[] = {
 void AsyncWrap::DestroyAsyncIdsCallback(Environment* env) {
   Local<Function> fn = env->async_hooks_destroy_function();
 
-  TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
+  TryCatchScope try_catch(env,
+                          TryCatchScope::CatchMode::kFatalRethrowStackOverflow);
 
   do {
     std::vector<double> destroy_async_id_list;
@@ -95,7 +96,8 @@ void Emit(Environment* env, double async_id, AsyncHooks::Fields type,
 
   HandleScope handle_scope(env->isolate());
   Local<Value> async_id_value = Number::New(env->isolate(), async_id);
-  TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
+  TryCatchScope try_catch(env,
+                          TryCatchScope::CatchMode::kFatalRethrowStackOverflow);
   USE(fn->Call(env->context(), Undefined(env->isolate()), 1, &async_id_value));
 }
 
@@ -649,7 +651,8 @@ void AsyncWrap::EmitAsyncInit(Environment* env,
     object,
   };
 
-  TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal);
+  TryCatchScope try_catch(env,
+                          TryCatchScope::CatchMode::kFatalRethrowStackOverflow);
   USE(init_fn->Call(env->context(), object, arraysize(argv), argv));
 }
 
index 11bf2cc8b4391623479fd1382cc96aaf62d9e2c1..f525ae33f45fdb631fdad5d5b6f6c3f9d9e79253 100644 (file)
@@ -33,6 +33,7 @@ using v8::StackTrace;
 using v8::String;
 using v8::Undefined;
 using v8::Value;
+using v8::EscapableHandleScope;
 
 bool IsExceptionDecorated(Environment* env, Local<Value> er) {
   if (!er.IsEmpty() && er->IsObject()) {
@@ -185,6 +186,40 @@ static std::string GetErrorSource(Isolate* isolate,
   return buf + std::string(underline_buf, off);
 }
 
+static std::atomic<bool> is_in_oom{false};
+static thread_local std::atomic<bool> is_retrieving_js_stacktrace{false};
+MaybeLocal<StackTrace> GetCurrentStackTrace(Isolate* isolate, int frame_count) {
+  if (isolate == nullptr) {
+    return MaybeLocal<StackTrace>();
+  }
+  // Generating JavaScript stack trace can result in V8 fatal error,
+  // which can re-enter this function.
+  if (is_retrieving_js_stacktrace.load()) {
+    return MaybeLocal<StackTrace>();
+  }
+
+  // Can not capture the stacktrace when the isolate is in a OOM state or no
+  // context is entered.
+  if (is_in_oom.load() || !isolate->InContext()) {
+    return MaybeLocal<StackTrace>();
+  }
+
+  constexpr StackTrace::StackTraceOptions options =
+      static_cast<StackTrace::StackTraceOptions>(
+          StackTrace::kDetailed |
+          StackTrace::kExposeFramesAcrossSecurityOrigins);
+
+  is_retrieving_js_stacktrace.store(true);
+  EscapableHandleScope scope(isolate);
+  Local<StackTrace> stack =
+      StackTrace::CurrentStackTrace(isolate, frame_count, options);
+
+  is_retrieving_js_stacktrace.store(false);
+
+  return scope.Escape(stack);
+}
+
+
 static std::string FormatStackTrace(Isolate* isolate, Local<StackTrace> stack) {
   std::string result;
   for (int i = 0; i < stack->GetFrameCount(); i++) {
@@ -583,6 +618,34 @@ v8::ModifyCodeGenerationFromStringsResult ModifyCodeGenerationFromStrings(
   };
 }
 
+// Check if an exception is a stack overflow error (RangeError with
+// "Maximum call stack size exceeded" message). This is used to handle
+// stack overflow specially in TryCatchScope - instead of immediately
+// exiting, we can use the red zone to re-throw to user code.
+static bool IsStackOverflowError(Isolate* isolate, Local<Value> exception) {
+  if (!exception->IsNativeError()) return false;
+
+  Local<Object> err_obj = exception.As<Object>();
+  Local<String> constructor_name = err_obj->GetConstructorName();
+
+  // Must be a RangeError
+  Utf8Value name(isolate, constructor_name);
+  if (name.ToStringView() != "RangeError") return false;
+
+  // Check for the specific stack overflow message
+  Local<Context> context = isolate->GetCurrentContext();
+  Local<Value> message_val;
+  if (!err_obj->Get(context, String::NewFromUtf8Literal(isolate, "message"))
+           .ToLocal(&message_val)) {
+    return false;
+  }
+
+  if (!message_val->IsString()) return false;
+
+  Utf8Value message(isolate, message_val.As<String>());
+  return message.ToStringView() == "Maximum call stack size exceeded";
+}
+
 namespace errors {
 
 TryCatchScope::~TryCatchScope() {
@@ -1129,8 +1192,26 @@ void TriggerUncaughtException(Isolate* isolate,
   if (env->can_call_into_js()) {
     // We do not expect the global uncaught exception itself to throw any more
     // exceptions. If it does, exit the current Node.js instance.
-    errors::TryCatchScope try_catch(env,
-                                    errors::TryCatchScope::CatchMode::kFatal);
+    // Special case: if the original error was a stack overflow and calling
+    // _fatalException causes another stack overflow, rethrow it to allow
+    // user code's try-catch blocks to potentially catch it.
+    auto is_stack_overflow = [&] {
+      return IsStackOverflowError(env->isolate(), error);
+    };
+    // Without a JS stack, rethrowing may or may not do anything.
+    // TODO(addaleax): In V8, expose a way to check whether there is a JS stack
+    // or TryCatch that would capture the rethrown exception.
+    auto has_js_stack = [&] {
+      HandleScope handle_scope(env->isolate());
+      Local<StackTrace> stack;
+      return GetCurrentStackTrace(env->isolate(), 1).ToLocal(&stack) &&
+             stack->GetFrameCount() > 0;
+    };
+    errors::TryCatchScope::CatchMode mode =
+        is_stack_overflow() && has_js_stack()
+            ? errors::TryCatchScope::CatchMode::kFatalRethrowStackOverflow
+            : errors::TryCatchScope::CatchMode::kFatal;
+    errors::TryCatchScope try_catch(env, mode);
     // Explicitly disable verbose exception reporting -
     // if process._fatalException() throws an error, we don't want it to
     // trigger the per-isolate message listener which will call this
index cc336536af0170e63cf9ccde99f9fa0a0be3f216..04aa3c79793a1064774309f9374be397a772cdd9 100644 (file)
@@ -238,7 +238,7 @@ namespace errors {
 
 class TryCatchScope : public v8::TryCatch {
  public:
-  enum class CatchMode { kNormal, kFatal };
+  enum class CatchMode { kNormal, kFatal, kFatalRethrowStackOverflow };
 
   explicit TryCatchScope(Environment* env, CatchMode mode = CatchMode::kNormal)
       : v8::TryCatch(env->isolate()), env_(env), mode_(mode) {}
diff --git a/test/parallel/test-async-hooks-stack-overflow-nested-async.js b/test/parallel/test-async-hooks-stack-overflow-nested-async.js
new file mode 100644 (file)
index 0000000..779f8d7
--- /dev/null
@@ -0,0 +1,80 @@
+'use strict';
+
+// This test verifies that stack overflow during deeply nested async operations
+// with async_hooks enabled can be caught by try-catch. This simulates real-world
+// scenarios like processing deeply nested JSON structures where each level
+// creates async operations (e.g., database calls, API requests).
+
+require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+
+if (process.argv[2] === 'child') {
+  const { createHook } = require('async_hooks');
+
+  // Enable async_hooks with all callbacks (simulates APM tools)
+  createHook({
+    init() {},
+    before() {},
+    after() {},
+    destroy() {},
+    promiseResolve() {},
+  }).enable();
+
+  // Simulate an async operation (like a database call or API request)
+  async function fetchThing(id) {
+    return { id, data: `data-${id}` };
+  }
+
+  // Recursively process deeply nested data structure
+  // This will cause stack overflow when the nesting is deep enough
+  function processData(data, depth = 0) {
+    if (Array.isArray(data)) {
+      for (const item of data) {
+        // Create a promise to trigger async_hooks init callback
+        fetchThing(depth);
+        processData(item, depth + 1);
+      }
+    }
+  }
+
+  // Create deeply nested array structure iteratively (to avoid stack overflow
+  // during creation)
+  function createNestedArray(depth) {
+    let result = 'leaf';
+    for (let i = 0; i < depth; i++) {
+      result = [result];
+    }
+    return result;
+  }
+
+  // Create a very deep nesting that will cause stack overflow during processing
+  const deeplyNested = createNestedArray(50000);
+
+  try {
+    processData(deeplyNested);
+    // Should not complete successfully - the nesting is too deep
+    console.log('UNEXPECTED: Processing completed without error');
+    process.exit(1);
+  } catch (err) {
+    assert.strictEqual(err.name, 'RangeError');
+    assert.match(err.message, /Maximum call stack size exceeded/);
+    console.log('SUCCESS: try-catch caught the stack overflow in nested async');
+    process.exit(0);
+  }
+} else {
+  // Parent process - spawn the child and check exit code
+  const result = spawnSync(
+    process.execPath,
+    [__filename, 'child'],
+    { encoding: 'utf8', timeout: 30000 }
+  );
+
+  // Should exit successfully (try-catch worked)
+  assert.strictEqual(result.status, 0,
+                     `Expected exit code 0, got ${result.status}.\n` +
+                     `stdout: ${result.stdout}\n` +
+                     `stderr: ${result.stderr}`);
+  // Verify the error was handled by try-catch
+  assert.match(result.stdout, /SUCCESS: try-catch caught the stack overflow/);
+}
diff --git a/test/parallel/test-async-hooks-stack-overflow-try-catch.js b/test/parallel/test-async-hooks-stack-overflow-try-catch.js
new file mode 100644 (file)
index 0000000..4333890
--- /dev/null
@@ -0,0 +1,47 @@
+'use strict';
+
+// This test verifies that when a stack overflow occurs with async_hooks
+// enabled, the exception can be caught by try-catch blocks in user code.
+
+require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+
+if (process.argv[2] === 'child') {
+  const { createHook } = require('async_hooks');
+
+  createHook({ init() {} }).enable();
+
+  function recursive(depth = 0) {
+    // Create a promise to trigger async_hooks init callback
+    new Promise(() => {});
+    return recursive(depth + 1);
+  }
+
+  try {
+    recursive();
+    // Should not reach here
+    process.exit(1);
+  } catch (err) {
+    assert.strictEqual(err.name, 'RangeError');
+    assert.match(err.message, /Maximum call stack size exceeded/);
+    console.log('SUCCESS: try-catch caught the stack overflow');
+    process.exit(0);
+  }
+
+  // Should not reach here
+  process.exit(2);
+} else {
+  // Parent process - spawn the child and check exit code
+  const result = spawnSync(
+    process.execPath,
+    [__filename, 'child'],
+    { encoding: 'utf8', timeout: 30000 }
+  );
+
+  assert.strictEqual(result.status, 0,
+                     `Expected exit code 0 (try-catch worked), got ${result.status}.\n` +
+                     `stdout: ${result.stdout}\n` +
+                     `stderr: ${result.stderr}`);
+  assert.match(result.stdout, /SUCCESS: try-catch caught the stack overflow/);
+}
diff --git a/test/parallel/test-async-hooks-stack-overflow.js b/test/parallel/test-async-hooks-stack-overflow.js
new file mode 100644 (file)
index 0000000..aff4196
--- /dev/null
@@ -0,0 +1,47 @@
+'use strict';
+
+// This test verifies that when a stack overflow occurs with async_hooks
+// enabled, the uncaughtException handler is still called instead of the
+// process crashing with exit code 7.
+
+const common = require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+
+if (process.argv[2] === 'child') {
+  const { createHook } = require('async_hooks');
+
+  let handlerCalled = false;
+
+  function recursive() {
+    // Create a promise to trigger async_hooks init callback
+    new Promise(() => {});
+    return recursive();
+  }
+
+  createHook({ init() {} }).enable();
+
+  process.on('uncaughtException', common.mustCall((err) => {
+    assert.strictEqual(err.name, 'RangeError');
+    assert.match(err.message, /Maximum call stack size exceeded/);
+    // Ensure handler is only called once
+    assert.strictEqual(handlerCalled, false);
+    handlerCalled = true;
+  }));
+
+  setImmediate(recursive);
+} else {
+  // Parent process - spawn the child and check exit code
+  const result = spawnSync(
+    process.execPath,
+    [__filename, 'child'],
+    { encoding: 'utf8', timeout: 30000 }
+  );
+
+  // Should exit with code 0 (handler was called and handled the exception)
+  // Previously would exit with code 7 (kExceptionInFatalExceptionHandler)
+  assert.strictEqual(result.status, 0,
+                     `Expected exit code 0, got ${result.status}.\n` +
+                     `stdout: ${result.stdout}\n` +
+                     `stderr: ${result.stderr}`);
+}
diff --git a/test/parallel/test-uncaught-exception-handler-stack-overflow-on-stack-overflow.js b/test/parallel/test-uncaught-exception-handler-stack-overflow-on-stack-overflow.js
new file mode 100644 (file)
index 0000000..1923b7f
--- /dev/null
@@ -0,0 +1,29 @@
+'use strict';
+
+// This test verifies that when the uncaughtException handler itself causes
+// a stack overflow, the process exits with a non-zero exit code.
+// This is important to ensure we don't silently swallow errors.
+
+require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+
+if (process.argv[2] === 'child') {
+  function f() { f(); }
+  process.on('uncaughtException', f);
+  f();
+} else {
+  // Parent process - spawn the child and check exit code
+  const result = spawnSync(
+    process.execPath,
+    [__filename, 'child'],
+    { encoding: 'utf8', timeout: 30000 }
+  );
+
+  // Should exit with non-zero exit code since the uncaughtException handler
+  // itself caused a stack overflow.
+  assert.notStrictEqual(result.status, 0,
+                        `Expected non-zero exit code, got ${result.status}.\n` +
+                        `stdout: ${result.stdout}\n` +
+                        `stderr: ${result.stderr}`);
+}
diff --git a/test/parallel/test-uncaught-exception-handler-stack-overflow.js b/test/parallel/test-uncaught-exception-handler-stack-overflow.js
new file mode 100644 (file)
index 0000000..050cd09
--- /dev/null
@@ -0,0 +1,29 @@
+'use strict';
+
+// This test verifies that when the uncaughtException handler itself causes
+// a stack overflow, the process exits with a non-zero exit code.
+// This is important to ensure we don't silently swallow errors.
+
+require('../common');
+const assert = require('assert');
+const { spawnSync } = require('child_process');
+
+if (process.argv[2] === 'child') {
+  function f() { f(); }
+  process.on('uncaughtException', f);
+  throw new Error('X');
+} else {
+  // Parent process - spawn the child and check exit code
+  const result = spawnSync(
+    process.execPath,
+    [__filename, 'child'],
+    { encoding: 'utf8', timeout: 30000 }
+  );
+
+  // Should exit with non-zero exit code since the uncaughtException handler
+  // itself caused a stack overflow.
+  assert.notStrictEqual(result.status, 0,
+                        `Expected non-zero exit code, got ${result.status}.\n` +
+                        `stdout: ${result.stdout}\n` +
+                        `stderr: ${result.stderr}`);
+}