From: Matteo Collina Date: Sun, 26 Apr 2026 15:21:57 +0000 (+0200) Subject: src: rethrow stack overflow exceptions in async_hooks When a stack overflow exception... X-Git-Tag: archive/raspbian/18.20.4+dfsg-1_deb12u2+rpi1^2~8 X-Git-Url: https://dgit.raspbian.org/?a=commitdiff_plain;h=cab95b46786107d4a6053958c1218c46542d23a8;p=nodejs.git src: rethrow stack overflow exceptions in async_hooks When a stack overflow exception occurs during async_hooks callbacks (which use TryCatchScope::kFatal), detect the specific "Maximum call stack size exceeded" RangeError and re-throw it instead of immediately calling FatalException. This allows user code to catch the exception with try-catch blocks instead of requiring uncaughtException handlers. 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 Reviewed-By: Paolo Insogna Reviewed-By: Marco Ippolito Reviewed-By: Rafael Gonzaga Reviewed-By: Anna Henningsen 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 --- diff --git a/src/async_wrap.cc b/src/async_wrap.cc index 697eff06c..44150e4a7 100644 --- a/src/async_wrap.cc +++ b/src/async_wrap.cc @@ -66,7 +66,8 @@ static const char* const provider_names[] = { void AsyncWrap::DestroyAsyncIdsCallback(Environment* env) { Local fn = env->async_hooks_destroy_function(); - TryCatchScope try_catch(env, TryCatchScope::CatchMode::kFatal); + TryCatchScope try_catch(env, + TryCatchScope::CatchMode::kFatalRethrowStackOverflow); do { std::vector destroy_async_id_list; @@ -95,7 +96,8 @@ void Emit(Environment* env, double async_id, AsyncHooks::Fields type, HandleScope handle_scope(env->isolate()); Local 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)); } diff --git a/src/node_errors.cc b/src/node_errors.cc index 11bf2cc8b..f525ae33f 100644 --- a/src/node_errors.cc +++ b/src/node_errors.cc @@ -33,6 +33,7 @@ using v8::StackTrace; using v8::String; using v8::Undefined; using v8::Value; +using v8::EscapableHandleScope; bool IsExceptionDecorated(Environment* env, Local 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 is_in_oom{false}; +static thread_local std::atomic is_retrieving_js_stacktrace{false}; +MaybeLocal GetCurrentStackTrace(Isolate* isolate, int frame_count) { + if (isolate == nullptr) { + return MaybeLocal(); + } + // Generating JavaScript stack trace can result in V8 fatal error, + // which can re-enter this function. + if (is_retrieving_js_stacktrace.load()) { + return MaybeLocal(); + } + + // 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(); + } + + constexpr StackTrace::StackTraceOptions options = + static_cast( + StackTrace::kDetailed | + StackTrace::kExposeFramesAcrossSecurityOrigins); + + is_retrieving_js_stacktrace.store(true); + EscapableHandleScope scope(isolate); + Local stack = + StackTrace::CurrentStackTrace(isolate, frame_count, options); + + is_retrieving_js_stacktrace.store(false); + + return scope.Escape(stack); +} + + static std::string FormatStackTrace(Isolate* isolate, Local 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 exception) { + if (!exception->IsNativeError()) return false; + + Local err_obj = exception.As(); + Local 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 = isolate->GetCurrentContext(); + Local 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()); + 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 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 diff --git a/src/node_errors.h b/src/node_errors.h index cc336536a..04aa3c797 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -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 index 000000000..779f8d75a --- /dev/null +++ b/test/parallel/test-async-hooks-stack-overflow-nested-async.js @@ -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 index 000000000..43338905e --- /dev/null +++ b/test/parallel/test-async-hooks-stack-overflow-try-catch.js @@ -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 index 000000000..aff41969d --- /dev/null +++ b/test/parallel/test-async-hooks-stack-overflow.js @@ -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 index 000000000..1923b7f24 --- /dev/null +++ b/test/parallel/test-uncaught-exception-handler-stack-overflow-on-stack-overflow.js @@ -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 index 000000000..050cd0923 --- /dev/null +++ b/test/parallel/test-uncaught-exception-handler-stack-overflow.js @@ -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}`); +}