From fb4389332dc4d6e48faa5ff17e662a5455ba68ab Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Tue, 21 Oct 2025 18:25:31 -0300 Subject: [PATCH 1/9] lib: disable futimes when permission model is enabled Refs: https://hackerone.com/reports/3390084 PR-URL: https://github.com/nodejs-private/node-private/pull/748 Reviewed-By: Matteo Collina Reviewed-By: Anna Henningsen CVE-ID: CVE-2025-55132 --- lib/fs.js | 24 ++++++++++ test/fixtures/permission/fs-write.js | 45 +++++++++++++++++++ test/parallel/test-permission-fs-supported.js | 17 ++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/lib/fs.js b/lib/fs.js index cde3a582727f80..96d2c905ec0dae 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -1224,6 +1224,11 @@ function rmSync(path, options) { function fdatasync(fd, callback) { const req = new FSReqCallback(); req.oncomplete = makeCallback(callback); + + if (permission.isEnabled()) { + callback(new ERR_ACCESS_DENIED('fdatasync API is disabled when Permission Model is enabled.')); + return; + } binding.fdatasync(fd, req); } @@ -1235,6 +1240,9 @@ function fdatasync(fd, callback) { * @returns {void} */ function fdatasyncSync(fd) { + if (permission.isEnabled()) { + throw new ERR_ACCESS_DENIED('fdatasync API is disabled when Permission Model is enabled.'); + } binding.fdatasync(fd); } @@ -1248,6 +1256,10 @@ function fdatasyncSync(fd) { function fsync(fd, callback) { const req = new FSReqCallback(); req.oncomplete = makeCallback(callback); + if (permission.isEnabled()) { + callback(new ERR_ACCESS_DENIED('fsync API is disabled when Permission Model is enabled.')); + return; + } binding.fsync(fd, req); } @@ -1258,6 +1270,9 @@ function fsync(fd, callback) { * @returns {void} */ function fsyncSync(fd) { + if (permission.isEnabled()) { + throw new ERR_ACCESS_DENIED('fsync API is disabled when Permission Model is enabled.'); + } binding.fsync(fd); } @@ -2188,6 +2203,11 @@ function futimes(fd, atime, mtime, callback) { mtime = toUnixTimestamp(mtime, 'mtime'); callback = makeCallback(callback); + if (permission.isEnabled()) { + callback(new ERR_ACCESS_DENIED('futimes API is disabled when Permission Model is enabled.')); + return; + } + const req = new FSReqCallback(); req.oncomplete = callback; binding.futimes(fd, atime, mtime, req); @@ -2203,6 +2223,10 @@ function futimes(fd, atime, mtime, callback) { * @returns {void} */ function futimesSync(fd, atime, mtime) { + if (permission.isEnabled()) { + throw new ERR_ACCESS_DENIED('futimes API is disabled when Permission Model is enabled.'); + } + binding.futimes( fd, toUnixTimestamp(atime, 'atime'), diff --git a/test/fixtures/permission/fs-write.js b/test/fixtures/permission/fs-write.js index cd57591c3b0d28..27d88911ef8b49 100644 --- a/test/fixtures/permission/fs-write.js +++ b/test/fixtures/permission/fs-write.js @@ -584,3 +584,48 @@ const relativeProtectedFolder = process.env.RELATIVEBLOCKEDFOLDER; code: 'ERR_ACCESS_DENIED', }); } + +// fs.utimes with read-only fd +{ + assert.throws(() => { + // blocked file is allowed to read + const fd = fs.openSync(blockedFile, 'r'); + const date = new Date(); + date.setFullYear(2100,0,1); + + fs.futimes(fd, date, date, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + })); + fs.futimesSync(fd, date, date); + }, { + code: 'ERR_ACCESS_DENIED', + }); +} + +// fs.fdatasync with read-only fd +{ + assert.throws(() => { + // blocked file is allowed to read + const fd = fs.openSync(blockedFile, 'r'); + fs.fdatasync(fd, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + })); + fs.fdatasyncSync(fd); + }, { + code: 'ERR_ACCESS_DENIED', + }); +} + +// fs.fsync with read-only fd +{ + assert.throws(() => { + // blocked file is allowed to read + const fd = fs.openSync(blockedFile, 'r'); + fs.fsync(fd, common.expectsError({ + code: 'ERR_ACCESS_DENIED', + })); + fs.fsyncSync(fd); + }, { + code: 'ERR_ACCESS_DENIED', + }); +} \ No newline at end of file diff --git a/test/parallel/test-permission-fs-supported.js b/test/parallel/test-permission-fs-supported.js index bf5127efd91a8a..0c0da9500f2599 100644 --- a/test/parallel/test-permission-fs-supported.js +++ b/test/parallel/test-permission-fs-supported.js @@ -82,7 +82,22 @@ const ignoreList = [ 'unwatchFile', ...syncAndAsyncAPI('lstat'), ...syncAndAsyncAPI('realpath'), - // fd required methods + // File descriptor–based metadata operations + // + // The kernel does not allow opening a file descriptor for an inode + // with write access if the inode itself is read-only. However, it still + // permits modifying the inode’s metadata (e.g., permission bits, ownership, + // timestamps) because you own the file. These changes can be made either + // by referring to the file by name (e.g., chmod) or through any existing + // file descriptor that identifies the same inode (e.g., fchmod). + // + // If the kernel required write access to change metadata, it would be + // impossible to modify the permissions of a file once it was made read-only. + // For that reason, syscalls such as fchmod, fchown, and futimes bypass + // the file descriptor’s access mode. Even a read-only ('r') descriptor + // can still update metadata. To prevent unintended modifications, + // these APIs are therefore blocked by default when permission model is + // enabled. ...syncAndAsyncAPI('close'), ...syncAndAsyncAPI('fchown'), ...syncAndAsyncAPI('fchmod'), From 7d421c8f29a48a5fe601329ccaf5c16239b3a2bb Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Fri, 31 Oct 2025 16:27:48 -0300 Subject: [PATCH 2/9] lib: add TLSSocket default error handler This prevents the server from crashing due to an unhandled rejection when a TLSSocket connection is abruptly destroyed during initialization and the user has not attached an error handler to the socket. e.g: ```js const server = http2.createSecureServer({ ... }) server.on('secureConnection', socket => { socket.on('error', err => { console.log(err) }) }) ``` PR-URL: https://github.com/nodejs-private/node-private/pull/750 Fixes: https://github.com/nodejs/node/issues/44751 Refs: https://hackerone.com/bugs?subject=nodejs&report_id=3262404 Reviewed-By: Matteo Collina Reviewed-By: Anna Henningsen CVE-ID: CVE-2025-59465 --- lib/internal/tls/wrap.js | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js index 82cf6832c3efa8..1cdfbf0f43fbd3 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -1252,6 +1252,7 @@ function tlsConnectionListener(rawSocket) { socket[kErrorEmitted] = false; socket.on('close', onSocketClose); socket.on('_tlsError', onSocketTLSError); + socket.on('error', onSocketTLSError); } // AUTHENTICATION MODES From 3296b2e734cc1b329f3c32aee2c7bdeb4ade7cf4 Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Mon, 10 Nov 2025 19:27:51 -0300 Subject: [PATCH 3/9] lib,permission: require full read and write to symlink APIs Refs: https://hackerone.com/reports/3417819 Signed-off-by: RafaelGSS PR-URL: https://github.com/nodejs-private/node-private/pull/760 Reviewed-By: Matteo Collina CVE-ID: CVE-2025-55130 --- lib/fs.js | 34 ++++++------------- lib/internal/fs/promises.js | 20 +++-------- .../permission/fs-symlink-target-write.js | 18 ++-------- test/fixtures/permission/fs-symlink.js | 18 ++++++++-- .../test-permission-fs-symlink-relative.js | 10 +++--- test/parallel/test-permission-fs-symlink.js | 14 ++++++++ 6 files changed, 52 insertions(+), 62 deletions(-) diff --git a/lib/fs.js b/lib/fs.js index 96d2c905ec0dae..ace5c464b73b14 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -60,7 +60,6 @@ const { } = constants; const pathModule = require('path'); -const { isAbsolute } = pathModule; const { isArrayBufferView } = require('internal/util/types'); const binding = internalBinding('fs'); @@ -1778,18 +1777,12 @@ function symlink(target, path, type, callback) { validateOneOf(type, 'type', ['dir', 'file', 'junction', null, undefined]); } - if (permission.isEnabled()) { - // The permission model's security guarantees fall apart in the presence of - // relative symbolic links. Thus, we have to prevent their creation. - if (BufferIsBuffer(target)) { - if (!isAbsolute(BufferToString(target))) { - callback(new ERR_ACCESS_DENIED('relative symbolic link target')); - return; - } - } else if (typeof target !== 'string' || !isAbsolute(toPathIfFileURL(target))) { - callback(new ERR_ACCESS_DENIED('relative symbolic link target')); - return; - } + // Due to the nature of Node.js runtime, symlinks has different edge cases that can bypass + // the permission model security guarantees. Thus, this API is disabled unless fs.read + // and fs.write permission has been given. + if (permission.isEnabled() && !permission.has('fs')) { + callback(new ERR_ACCESS_DENIED('fs.symlink API requires full fs.read and fs.write permissions.')); + return; } target = getValidatedPath(target, 'target'); @@ -1853,16 +1846,11 @@ function symlinkSync(target, path, type) { } } - if (permission.isEnabled()) { - // The permission model's security guarantees fall apart in the presence of - // relative symbolic links. Thus, we have to prevent their creation. - if (BufferIsBuffer(target)) { - if (!isAbsolute(BufferToString(target))) { - throw new ERR_ACCESS_DENIED('relative symbolic link target'); - } - } else if (typeof target !== 'string' || !isAbsolute(toPathIfFileURL(target))) { - throw new ERR_ACCESS_DENIED('relative symbolic link target'); - } + // Due to the nature of Node.js runtime, symlinks has different edge cases that can bypass + // the permission model security guarantees. Thus, this API is disabled unless fs.read + // and fs.write permission has been given. + if (permission.isEnabled() && !permission.has('fs')) { + throw new ERR_ACCESS_DENIED('fs.symlink API requires full fs.read and fs.write permissions.'); } target = getValidatedPath(target, 'target'); diff --git a/lib/internal/fs/promises.js b/lib/internal/fs/promises.js index c5fef35005f30b..9741dcd8516eca 100644 --- a/lib/internal/fs/promises.js +++ b/lib/internal/fs/promises.js @@ -17,7 +17,6 @@ const { Symbol, SymbolAsyncDispose, Uint8Array, - uncurryThis, } = primordials; const { fs: constants } = internalBinding('constants'); @@ -31,8 +30,6 @@ const { const binding = internalBinding('fs'); const { Buffer } = require('buffer'); -const { isBuffer: BufferIsBuffer } = Buffer; -const BufferToString = uncurryThis(Buffer.prototype.toString); const { AbortError, @@ -88,8 +85,6 @@ const { kValidateObjectAllowNullable, } = require('internal/validators'); const pathModule = require('path'); -const { isAbsolute } = pathModule; -const { toPathIfFileURL } = require('internal/url'); const { getLazy, kEmptyObject, @@ -992,16 +987,11 @@ async function symlink(target, path, type) { } } - if (permission.isEnabled()) { - // The permission model's security guarantees fall apart in the presence of - // relative symbolic links. Thus, we have to prevent their creation. - if (BufferIsBuffer(target)) { - if (!isAbsolute(BufferToString(target))) { - throw new ERR_ACCESS_DENIED('relative symbolic link target'); - } - } else if (typeof target !== 'string' || !isAbsolute(toPathIfFileURL(target))) { - throw new ERR_ACCESS_DENIED('relative symbolic link target'); - } + // Due to the nature of Node.js runtime, symlinks has different edge cases that can bypass + // the permission model security guarantees. Thus, this API is disabled unless fs.read + // and fs.write permission has been given. + if (permission.isEnabled() && !permission.has('fs')) { + throw new ERR_ACCESS_DENIED('fs.symlink API requires full fs.read and fs.write permissions.'); } target = getValidatedPath(target, 'target'); diff --git a/test/fixtures/permission/fs-symlink-target-write.js b/test/fixtures/permission/fs-symlink-target-write.js index c17d674d59ee97..6e07bfa838e2f5 100644 --- a/test/fixtures/permission/fs-symlink-target-write.js +++ b/test/fixtures/permission/fs-symlink-target-write.js @@ -26,8 +26,7 @@ const writeOnlyFolder = process.env.WRITEONLYFOLDER; fs.symlinkSync(path.join(readOnlyFolder, 'file'), path.join(readWriteFolder, 'link-to-read-only'), 'file'); }, common.expectsError({ code: 'ERR_ACCESS_DENIED', - permission: 'FileSystemWrite', - resource: path.toNamespacedPath(path.join(readOnlyFolder, 'file')), + message: 'fs.symlink API requires full fs.read and fs.write permissions.', })); assert.throws(() => { fs.linkSync(path.join(readOnlyFolder, 'file'), path.join(readWriteFolder, 'link-to-read-only')); @@ -37,18 +36,6 @@ const writeOnlyFolder = process.env.WRITEONLYFOLDER; resource: path.toNamespacedPath(path.join(readOnlyFolder, 'file')), })); - // App will be able to symlink to a writeOnlyFolder - fs.symlink(path.join(readWriteFolder, 'file'), path.join(writeOnlyFolder, 'link-to-read-write'), 'file', (err) => { - assert.ifError(err); - // App will won't be able to read the symlink - fs.readFile(path.join(writeOnlyFolder, 'link-to-read-write'), common.expectsError({ - code: 'ERR_ACCESS_DENIED', - permission: 'FileSystemRead', - })); - - // App will be able to write to the symlink - fs.writeFile(path.join(writeOnlyFolder, 'link-to-read-write'), 'some content', common.mustSucceed()); - }); fs.link(path.join(readWriteFolder, 'file'), path.join(writeOnlyFolder, 'link-to-read-write2'), (err) => { assert.ifError(err); // App will won't be able to read the link @@ -66,8 +53,7 @@ const writeOnlyFolder = process.env.WRITEONLYFOLDER; fs.symlinkSync(path.join(readWriteFolder, 'file'), path.join(readOnlyFolder, 'link-to-read-only'), 'file'); }, common.expectsError({ code: 'ERR_ACCESS_DENIED', - permission: 'FileSystemWrite', - resource: path.toNamespacedPath(path.join(readOnlyFolder, 'link-to-read-only')), + message: 'fs.symlink API requires full fs.read and fs.write permissions.', })); assert.throws(() => { fs.linkSync(path.join(readWriteFolder, 'file'), path.join(readOnlyFolder, 'link-to-read-only')); diff --git a/test/fixtures/permission/fs-symlink.js b/test/fixtures/permission/fs-symlink.js index 4cf3b45f0ebcfb..ba60f7811bdde5 100644 --- a/test/fixtures/permission/fs-symlink.js +++ b/test/fixtures/permission/fs-symlink.js @@ -54,7 +54,6 @@ const symlinkFromBlockedFile = process.env.EXISTINGSYMLINK; fs.readFileSync(blockedFile); }, common.expectsError({ code: 'ERR_ACCESS_DENIED', - permission: 'FileSystemRead', })); assert.throws(() => { fs.appendFileSync(blockedFile, 'data'); @@ -68,7 +67,6 @@ const symlinkFromBlockedFile = process.env.EXISTINGSYMLINK; fs.symlinkSync(regularFile, blockedFolder + '/asdf', 'file'); }, common.expectsError({ code: 'ERR_ACCESS_DENIED', - permission: 'FileSystemWrite', })); assert.throws(() => { fs.linkSync(regularFile, blockedFolder + '/asdf'); @@ -82,7 +80,6 @@ const symlinkFromBlockedFile = process.env.EXISTINGSYMLINK; fs.symlinkSync(blockedFile, path.join(__dirname, '/asdf'), 'file'); }, common.expectsError({ code: 'ERR_ACCESS_DENIED', - permission: 'FileSystemRead', })); assert.throws(() => { fs.linkSync(blockedFile, path.join(__dirname, '/asdf')); @@ -90,4 +87,19 @@ const symlinkFromBlockedFile = process.env.EXISTINGSYMLINK; code: 'ERR_ACCESS_DENIED', permission: 'FileSystemRead', })); +} + +// fs.symlink API is blocked by default +{ + assert.throws(() => { + fs.symlinkSync(regularFile, regularFile); + }, common.expectsError({ + message: 'fs.symlink API requires full fs.read and fs.write permissions.', + code: 'ERR_ACCESS_DENIED', + })); + + fs.symlink(regularFile, regularFile, common.expectsError({ + message: 'fs.symlink API requires full fs.read and fs.write permissions.', + code: 'ERR_ACCESS_DENIED', + })); } \ No newline at end of file diff --git a/test/parallel/test-permission-fs-symlink-relative.js b/test/parallel/test-permission-fs-symlink-relative.js index e1fe5d064a8756..84f6210333b798 100644 --- a/test/parallel/test-permission-fs-symlink-relative.js +++ b/test/parallel/test-permission-fs-symlink-relative.js @@ -1,4 +1,4 @@ -// Flags: --permission --allow-fs-read=* --allow-fs-write=* +// Flags: --permission --allow-fs-read=* 'use strict'; const common = require('../common'); @@ -15,7 +15,7 @@ const { symlinkSync, symlink, promises: { symlink: symlinkAsync } } = require('f const error = { code: 'ERR_ACCESS_DENIED', - message: /relative symbolic link target/, + message: /symlink API requires full fs\.read and fs\.write permissions/, }; for (const targetString of ['a', './b/c', '../d', 'e/../f', 'C:drive-relative', 'ntfs:alternate']) { @@ -32,14 +32,14 @@ for (const targetString of ['a', './b/c', '../d', 'e/../f', 'C:drive-relative', } } -// Absolute should not throw +// Absolute should throw too for (const targetString of [path.resolve('.')]) { for (const target of [targetString, Buffer.from(targetString)]) { for (const path of [__filename]) { symlink(target, path, common.mustCall((err) => { assert(err); - assert.strictEqual(err.code, 'EEXIST'); - assert.match(err.message, /file already exists/); + assert.strictEqual(err.code, error.code); + assert.match(err.message, error.message); })); } } diff --git a/test/parallel/test-permission-fs-symlink.js b/test/parallel/test-permission-fs-symlink.js index e5a80dba44ddf4..d918e4ea0fefeb 100644 --- a/test/parallel/test-permission-fs-symlink.js +++ b/test/parallel/test-permission-fs-symlink.js @@ -27,15 +27,26 @@ const commonPathWildcard = path.join(__filename, '../../common*'); const blockedFile = fixtures.path('permission', 'deny', 'protected-file.md'); const blockedFolder = tmpdir.resolve('subdirectory'); const symlinkFromBlockedFile = tmpdir.resolve('example-symlink.md'); +const allowedFolder = tmpdir.resolve('allowed-folder'); +const traversalSymlink = path.join(allowedFolder, 'deep1', 'deep2', 'deep3', 'gotcha'); { tmpdir.refresh(); fs.mkdirSync(blockedFolder); + // Create deep directory structure for path traversal test + fs.mkdirSync(allowedFolder); + fs.writeFileSync(path.resolve(allowedFolder, '../protected-file.md'), 'protected'); + fs.mkdirSync(path.join(allowedFolder, 'deep1')); + fs.mkdirSync(path.join(allowedFolder, 'deep1', 'deep2')); + fs.mkdirSync(path.join(allowedFolder, 'deep1', 'deep2', 'deep3')); } { // Symlink previously created + // fs.symlink API is allowed when full-read and full-write access fs.symlinkSync(blockedFile, symlinkFromBlockedFile); + // Create symlink for path traversal test - symlink points to parent directory + fs.symlinkSync(allowedFolder, traversalSymlink); } { @@ -44,6 +55,7 @@ const symlinkFromBlockedFile = tmpdir.resolve('example-symlink.md'); [ '--permission', `--allow-fs-read=${file}`, `--allow-fs-read=${commonPathWildcard}`, `--allow-fs-read=${symlinkFromBlockedFile}`, + `--allow-fs-read=${allowedFolder}`, `--allow-fs-write=${symlinkFromBlockedFile}`, file, ], @@ -53,6 +65,8 @@ const symlinkFromBlockedFile = tmpdir.resolve('example-symlink.md'); BLOCKEDFOLDER: blockedFolder, BLOCKEDFILE: blockedFile, EXISTINGSYMLINK: symlinkFromBlockedFile, + TRAVERSALSYMLINK: traversalSymlink, + ALLOWEDFOLDER: allowedFolder, }, } ); From 73747597e0e2c4ddf2d437b0aee7f2cc1f0cdfef Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Tue, 9 Dec 2025 23:50:18 +0100 Subject: [PATCH 4/9] 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: https://github.com/nodejs/node/issues/37989 Ref: https://hackerone.com/reports/3456295 PR-URL: https://github.com/nodejs-private/node-private/pull/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 --- src/async_wrap.cc | 9 ++- src/debug_utils.cc | 3 +- src/node_errors.cc | 71 ++++++++++++++-- src/node_errors.h | 2 +- src/node_report.cc | 3 +- ...async-hooks-stack-overflow-nested-async.js | 80 +++++++++++++++++++ ...st-async-hooks-stack-overflow-try-catch.js | 47 +++++++++++ .../test-async-hooks-stack-overflow.js | 47 +++++++++++ ...andler-stack-overflow-on-stack-overflow.js | 29 +++++++ ...caught-exception-handler-stack-overflow.js | 29 +++++++ 10 files changed, 306 insertions(+), 14 deletions(-) create mode 100644 test/parallel/test-async-hooks-stack-overflow-nested-async.js create mode 100644 test/parallel/test-async-hooks-stack-overflow-try-catch.js create mode 100644 test/parallel/test-async-hooks-stack-overflow.js create mode 100644 test/parallel/test-uncaught-exception-handler-stack-overflow-on-stack-overflow.js create mode 100644 test/parallel/test-uncaught-exception-handler-stack-overflow.js diff --git a/src/async_wrap.cc b/src/async_wrap.cc index d9b2c76ede38c0..4a9e5fc1ab673c 100644 --- a/src/async_wrap.cc +++ b/src/async_wrap.cc @@ -68,7 +68,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; @@ -97,7 +98,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)); } @@ -646,7 +648,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/debug_utils.cc b/src/debug_utils.cc index 65283ef31da35d..2166e54aba3b11 100644 --- a/src/debug_utils.cc +++ b/src/debug_utils.cc @@ -333,7 +333,8 @@ void DumpJavaScriptBacktrace(FILE* fp) { } Local stack; - if (!GetCurrentStackTrace(isolate).ToLocal(&stack)) { + if (!GetCurrentStackTrace(isolate).ToLocal(&stack) || + stack->GetFrameCount() == 0) { return; } diff --git a/src/node_errors.cc b/src/node_errors.cc index ec84aaafbdc99a..30753ec2b07cd3 100644 --- a/src/node_errors.cc +++ b/src/node_errors.cc @@ -194,7 +194,7 @@ static std::string GetErrorSource(Isolate* isolate, } static std::atomic is_in_oom{false}; -static std::atomic is_retrieving_js_stacktrace{false}; +static thread_local std::atomic is_retrieving_js_stacktrace{false}; MaybeLocal GetCurrentStackTrace(Isolate* isolate, int frame_count) { if (isolate == nullptr) { return MaybeLocal(); @@ -222,9 +222,6 @@ MaybeLocal GetCurrentStackTrace(Isolate* isolate, int frame_count) { StackTrace::CurrentStackTrace(isolate, frame_count, options); is_retrieving_js_stacktrace.store(false); - if (stack->GetFrameCount() == 0) { - return MaybeLocal(); - } return scope.Escape(stack); } @@ -299,7 +296,8 @@ void PrintStackTrace(Isolate* isolate, void PrintCurrentStackTrace(Isolate* isolate, StackTracePrefix prefix) { Local stack; - if (GetCurrentStackTrace(isolate).ToLocal(&stack)) { + if (GetCurrentStackTrace(isolate).ToLocal(&stack) && + stack->GetFrameCount() > 0) { PrintStackTrace(isolate, stack, prefix); } } @@ -671,13 +669,52 @@ 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() { - if (HasCaught() && !HasTerminated() && mode_ == CatchMode::kFatal) { + if (HasCaught() && !HasTerminated() && mode_ != CatchMode::kNormal) { HandleScope scope(env_->isolate()); Local exception = Exception(); Local message = Message(); + + // Special handling for stack overflow errors in async_hooks: instead of + // immediately exiting, re-throw the exception. This allows the exception + // to propagate to user code's try-catch blocks. + if (mode_ == CatchMode::kFatalRethrowStackOverflow && + IsStackOverflowError(env_->isolate(), exception)) { + ReThrow(); + Reset(); + return; + } + EnhanceFatalException enhance = CanContinue() ? EnhanceFatalException::kEnhance : EnhanceFatalException::kDontEnhance; if (message.IsEmpty()) @@ -1277,8 +1314,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 e64ee5b1e7926c..191374210aae2b 100644 --- a/src/node_errors.h +++ b/src/node_errors.h @@ -328,7 +328,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/src/node_report.cc b/src/node_report.cc index e4cb681fd76890..5400ef9f4dbfaa 100644 --- a/src/node_report.cc +++ b/src/node_report.cc @@ -474,7 +474,8 @@ static void PrintJavaScriptStack(JSONWriter* writer, std::string_view trigger) { HandleScope scope(isolate); Local stack; - if (!GetCurrentStackTrace(isolate, MAX_FRAME_COUNT).ToLocal(&stack)) { + if (!GetCurrentStackTrace(isolate, MAX_FRAME_COUNT).ToLocal(&stack) || + stack->GetFrameCount() == 0) { PrintEmptyJavaScriptStack(writer); return; } 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 00000000000000..779f8d75ae208f --- /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 00000000000000..43338905e78bd9 --- /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 00000000000000..aff41969dbdf75 --- /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 00000000000000..1923b7f24d995d --- /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 00000000000000..050cd0923eea7b --- /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}`); +} From 1d2686d15e788218be35e1cb26dfbb551d8366e3 Mon Sep 17 00:00:00 2001 From: RafaelGSS Date: Sat, 27 Dec 2025 20:36:05 -0300 Subject: [PATCH 5/9] permission: add network check on pipe_wrap connect Refs: https://hackerone.com/reports/3465156 PR-URL: https://github.com/nodejs-private/node-private/pull/784 Reviewed-By: Anna Henningsen Reviewed-By: Marco Ippolito CVE-ID: CVE-2026-21636 --- src/pipe_wrap.cc | 3 +++ test/parallel/test-permission-net-uds.js | 31 ++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 test/parallel/test-permission-net-uds.js diff --git a/src/pipe_wrap.cc b/src/pipe_wrap.cc index 2cb61215604047..7bd15d59031284 100644 --- a/src/pipe_wrap.cc +++ b/src/pipe_wrap.cc @@ -226,6 +226,9 @@ void PipeWrap::Connect(const FunctionCallbackInfo& args) { Local req_wrap_obj = args[0].As(); node::Utf8Value name(env->isolate(), args[1]); + ERR_ACCESS_DENIED_IF_INSUFFICIENT_PERMISSIONS( + env, permission::PermissionScope::kNet, name.ToStringView(), args); + ConnectWrap* req_wrap = new ConnectWrap(env, req_wrap_obj, AsyncWrap::PROVIDER_PIPECONNECTWRAP); int err = req_wrap->Dispatch(uv_pipe_connect2, diff --git a/test/parallel/test-permission-net-uds.js b/test/parallel/test-permission-net-uds.js new file mode 100644 index 00000000000000..7024c9ff6d3b16 --- /dev/null +++ b/test/parallel/test-permission-net-uds.js @@ -0,0 +1,31 @@ +// Flags: --permission --allow-fs-read=* +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) { common.skip('missing crypto'); }; + +if (common.isWindows) { + common.skip('This test only works on unix'); +} + +const assert = require('assert'); +const net = require('net'); +const tls = require('tls'); + +{ + const client = net.connect({ path: '/tmp/perm.sock' }); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_ACCESS_DENIED'); + })); + + client.on('connect', common.mustNotCall('TCP connection should be blocked')); +} + +{ + const client = tls.connect({ path: '/tmp/perm.sock' }); + client.on('error', common.mustCall((err) => { + assert.strictEqual(err.code, 'ERR_ACCESS_DENIED'); + })); + + client.on('connect', common.mustNotCall('TCP connection should be blocked')); +} From 97595f768e94d3d2da685dc0e5366016b146d2b7 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 22 Dec 2025 18:25:33 +0100 Subject: [PATCH 6/9] tls: route callback exceptions through error handlers Wrap pskCallback and ALPNCallback invocations in try-catch blocks to route exceptions through owner.destroy() instead of letting them become uncaught exceptions. This prevents remote attackers from crashing TLS servers or causing resource exhaustion. Fixes: https://hackerone.com/reports/3473882 PR-URL: https://github.com/nodejs-private/node-private/pull/782 PR-URL: https://github.com/nodejs-private/node-private/pull/790 CVE-ID: CVE-2026-21637 --- lib/internal/tls/wrap.js | 157 ++++---- test/parallel/test-tls-alpn-server-client.js | 30 +- ...ls-psk-alpn-callback-exception-handling.js | 335 ++++++++++++++++++ 3 files changed, 442 insertions(+), 80 deletions(-) create mode 100644 test/parallel/test-tls-psk-alpn-callback-exception-handling.js diff --git a/lib/internal/tls/wrap.js b/lib/internal/tls/wrap.js index 1cdfbf0f43fbd3..7a1752649c4277 100644 --- a/lib/internal/tls/wrap.js +++ b/lib/internal/tls/wrap.js @@ -234,39 +234,44 @@ function callALPNCallback(protocolsBuffer) { const handle = this; const socket = handle[owner_symbol]; - const servername = handle.getServername(); + try { + const servername = handle.getServername(); - // Collect all the protocols from the given buffer: - const protocols = []; - let offset = 0; - while (offset < protocolsBuffer.length) { - const protocolLen = protocolsBuffer[offset]; - offset += 1; + // Collect all the protocols from the given buffer: + const protocols = []; + let offset = 0; + while (offset < protocolsBuffer.length) { + const protocolLen = protocolsBuffer[offset]; + offset += 1; - const protocol = protocolsBuffer.slice(offset, offset + protocolLen); - offset += protocolLen; + const protocol = protocolsBuffer.slice(offset, offset + protocolLen); + offset += protocolLen; - protocols.push(protocol.toString('ascii')); - } + protocols.push(protocol.toString('ascii')); + } - const selectedProtocol = socket[kALPNCallback]({ - servername, - protocols, - }); + const selectedProtocol = socket[kALPNCallback]({ + servername, + protocols, + }); - // Undefined -> all proposed protocols rejected - if (selectedProtocol === undefined) return undefined; + // Undefined -> all proposed protocols rejected + if (selectedProtocol === undefined) return undefined; - const protocolIndex = protocols.indexOf(selectedProtocol); - if (protocolIndex === -1) { - throw new ERR_TLS_ALPN_CALLBACK_INVALID_RESULT(selectedProtocol, protocols); - } - let protocolOffset = 0; - for (let i = 0; i < protocolIndex; i++) { - protocolOffset += 1 + protocols[i].length; - } + const protocolIndex = protocols.indexOf(selectedProtocol); + if (protocolIndex === -1) { + throw new ERR_TLS_ALPN_CALLBACK_INVALID_RESULT(selectedProtocol, protocols); + } + let protocolOffset = 0; + for (let i = 0; i < protocolIndex; i++) { + protocolOffset += 1 + protocols[i].length; + } - return protocolOffset; + return protocolOffset; + } catch (err) { + socket.destroy(err); + return undefined; + } } function requestOCSP(socket, info) { @@ -373,63 +378,75 @@ function onnewsession(sessionId, session) { function onPskServerCallback(identity, maxPskLen) { const owner = this[owner_symbol]; - const ret = owner[kPskCallback](owner, identity); - if (ret == null) - return undefined; - let psk; - if (isArrayBufferView(ret)) { - psk = ret; - } else { - if (typeof ret !== 'object') { - throw new ERR_INVALID_ARG_TYPE( - 'ret', - ['Object', 'Buffer', 'TypedArray', 'DataView'], - ret, + try { + const ret = owner[kPskCallback](owner, identity); + if (ret == null) + return undefined; + + let psk; + if (isArrayBufferView(ret)) { + psk = ret; + } else { + if (typeof ret !== 'object') { + throw new ERR_INVALID_ARG_TYPE( + 'ret', + ['Object', 'Buffer', 'TypedArray', 'DataView'], + ret, + ); + } + psk = ret.psk; + validateBuffer(psk, 'psk'); + } + + if (psk.length > maxPskLen) { + throw new ERR_INVALID_ARG_VALUE( + 'psk', + psk, + `Pre-shared key exceeds ${maxPskLen} bytes`, ); } - psk = ret.psk; - validateBuffer(psk, 'psk'); - } - if (psk.length > maxPskLen) { - throw new ERR_INVALID_ARG_VALUE( - 'psk', - psk, - `Pre-shared key exceeds ${maxPskLen} bytes`, - ); + return psk; + } catch (err) { + owner.destroy(err); + return undefined; } - - return psk; } function onPskClientCallback(hint, maxPskLen, maxIdentityLen) { const owner = this[owner_symbol]; - const ret = owner[kPskCallback](hint); - if (ret == null) - return undefined; - validateObject(ret, 'ret'); + try { + const ret = owner[kPskCallback](hint); + if (ret == null) + return undefined; + + validateObject(ret, 'ret'); + + validateBuffer(ret.psk, 'psk'); + if (ret.psk.length > maxPskLen) { + throw new ERR_INVALID_ARG_VALUE( + 'psk', + ret.psk, + `Pre-shared key exceeds ${maxPskLen} bytes`, + ); + } - validateBuffer(ret.psk, 'psk'); - if (ret.psk.length > maxPskLen) { - throw new ERR_INVALID_ARG_VALUE( - 'psk', - ret.psk, - `Pre-shared key exceeds ${maxPskLen} bytes`, - ); - } + validateString(ret.identity, 'identity'); + if (Buffer.byteLength(ret.identity) > maxIdentityLen) { + throw new ERR_INVALID_ARG_VALUE( + 'identity', + ret.identity, + `PSK identity exceeds ${maxIdentityLen} bytes`, + ); + } - validateString(ret.identity, 'identity'); - if (Buffer.byteLength(ret.identity) > maxIdentityLen) { - throw new ERR_INVALID_ARG_VALUE( - 'identity', - ret.identity, - `PSK identity exceeds ${maxIdentityLen} bytes`, - ); + return { psk: ret.psk, identity: ret.identity }; + } catch (err) { + owner.destroy(err); + return undefined; } - - return { psk: ret.psk, identity: ret.identity }; } function onkeylog(line) { diff --git a/test/parallel/test-tls-alpn-server-client.js b/test/parallel/test-tls-alpn-server-client.js index 8805311b72e146..92dfd493851056 100644 --- a/test/parallel/test-tls-alpn-server-client.js +++ b/test/parallel/test-tls-alpn-server-client.js @@ -253,24 +253,34 @@ function TestALPNCallback() { function TestBadALPNCallback() { // Server always returns a fixed invalid value: const serverOptions = { + key: loadPEM('agent2-key'), + cert: loadPEM('agent2-cert'), ALPNCallback: common.mustCall(() => 'http/5') }; - const clientsOptions = [{ - ALPNProtocols: ['http/1', 'h2'], - }]; + const server = tls.createServer(serverOptions); - process.once('uncaughtException', common.mustCall((error) => { + // Error should be emitted via tlsClientError, not as uncaughtException + server.on('tlsClientError', common.mustCall((error, socket) => { assert.strictEqual(error.code, 'ERR_TLS_ALPN_CALLBACK_INVALID_RESULT'); + socket.destroy(); })); - runTest(clientsOptions, serverOptions, common.mustCall((results) => { - // Callback returns 'http/5' => doesn't match client ALPN => error & reset - assert.strictEqual(results[0].server, undefined); - const allowedErrors = ['ECONNRESET', 'ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL']; - assert.ok(allowedErrors.includes(results[0].client.error.code), `'${results[0].client.error.code}' was not one of ${allowedErrors}.`); + server.listen(0, serverIP, common.mustCall(() => { + const client = tls.connect({ + port: server.address().port, + host: serverIP, + rejectUnauthorized: false, + ALPNProtocols: ['http/1', 'h2'], + }, common.mustNotCall()); - TestALPNOptionsCallback(); + client.on('error', common.mustCall((err) => { + // Client gets reset when server handles error via tlsClientError + const allowedErrors = ['ECONNRESET', 'ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL']; + assert.ok(allowedErrors.includes(err.code), `'${err.code}' was not one of ${allowedErrors}.`); + server.close(); + TestALPNOptionsCallback(); + })); })); } diff --git a/test/parallel/test-tls-psk-alpn-callback-exception-handling.js b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js new file mode 100644 index 00000000000000..e87b68d778035c --- /dev/null +++ b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js @@ -0,0 +1,335 @@ +'use strict'; + +// This test verifies that exceptions in pskCallback and ALPNCallback are +// properly routed through tlsClientError instead of becoming uncaught +// exceptions. This is a regression test for a vulnerability where callback +// validation errors would bypass all standard TLS error handlers. +// +// The vulnerability allows remote attackers to crash TLS servers or cause +// resource exhaustion (file descriptor leaks) when pskCallback or ALPNCallback +// throw exceptions during validation. + +const common = require('../common'); + +if (!common.hasCrypto) + common.skip('missing crypto'); + +const assert = require('assert'); +const { describe, it } = require('node:test'); +const tls = require('tls'); +const fixtures = require('../common/fixtures'); + +const CIPHERS = 'PSK+HIGH'; +const TEST_TIMEOUT = 5000; + +// Helper to create a promise that rejects on uncaughtException or timeout +function createTestPromise() { + const { promise, resolve, reject } = Promise.withResolvers(); + let settled = false; + + const cleanup = () => { + if (!settled) { + settled = true; + process.removeListener('uncaughtException', onUncaught); + clearTimeout(timeout); + } + }; + + const onUncaught = (err) => { + cleanup(); + reject(new Error( + `Uncaught exception instead of tlsClientError: ${err.code || err.message}` + )); + }; + + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Test timed out - tlsClientError was not emitted')); + }, TEST_TIMEOUT); + + process.on('uncaughtException', onUncaught); + + return { + resolve: (value) => { + cleanup(); + resolve(value); + }, + reject: (err) => { + cleanup(); + reject(err); + }, + promise, + }; +} + +describe('TLS callback exception handling', () => { + + // Test 1: PSK server callback returning invalid type should emit tlsClientError + it('pskCallback returning invalid type emits tlsClientError', async (t) => { + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => { + // Return invalid type (string instead of object/Buffer) + return 'invalid-should-be-object-or-buffer'; + }, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => ({ + psk: Buffer.alloc(32), + identity: 'test-identity', + }), + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 2: PSK server callback throwing should emit tlsClientError + it('pskCallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => { + throw new Error('Intentional callback error'); + }, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => ({ + psk: Buffer.alloc(32), + identity: 'test-identity', + }), + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 3: ALPN callback returning non-matching protocol should emit tlsClientError + it('ALPNCallback returning invalid result emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ALPNCallback: () => { + // Return a protocol not in the client's list + return 'invalid-protocol-not-in-list'; + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_TLS_ALPN_CALLBACK_INVALID_RESULT'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + rejectUnauthorized: false, + ALPNProtocols: ['http/1.1', 'h2'], + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 4: ALPN callback throwing should emit tlsClientError + it('ALPNCallback throwing emits tlsClientError', async (t) => { + const server = tls.createServer({ + key: fixtures.readKey('agent2-key.pem'), + cert: fixtures.readKey('agent2-cert.pem'), + ALPNCallback: () => { + throw new Error('Intentional ALPN callback error'); + }, + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('tlsClientError', common.mustCall((err, socket) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional ALPN callback error'); + socket.destroy(); + resolve(); + } catch (e) { + reject(e); + } + })); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + rejectUnauthorized: false, + ALPNProtocols: ['http/1.1'], + }); + + client.on('error', () => {}); + + await promise; + }); + + // Test 5: PSK client callback returning invalid type should emit error event + it('client pskCallback returning invalid type emits error', async (t) => { + const PSK = Buffer.alloc(32); + + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => PSK, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => { + // Return invalid type - should cause validation error + return 'invalid-should-be-object'; + }, + }); + + client.on('error', common.mustCall((err) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.code, 'ERR_INVALID_ARG_TYPE'); + resolve(); + } catch (e) { + reject(e); + } + })); + + await promise; + }); + + // Test 6: PSK client callback throwing should emit error event + it('client pskCallback throwing emits error', async (t) => { + const PSK = Buffer.alloc(32); + + const server = tls.createServer({ + ciphers: CIPHERS, + pskCallback: () => PSK, + pskIdentityHint: 'test-hint', + }); + + t.after(() => server.close()); + + const { promise, resolve, reject } = createTestPromise(); + + server.on('secureConnection', () => { + reject(new Error('secureConnection should not fire')); + }); + + await new Promise((res) => server.listen(0, res)); + + const client = tls.connect({ + port: server.address().port, + host: '127.0.0.1', + ciphers: CIPHERS, + checkServerIdentity: () => {}, + pskCallback: () => { + throw new Error('Intentional client PSK callback error'); + }, + }); + + client.on('error', common.mustCall((err) => { + try { + assert.ok(err instanceof Error); + assert.strictEqual(err.message, 'Intentional client PSK callback error'); + resolve(); + } catch (e) { + reject(e); + } + })); + + await promise; + }); +}); From 3cdb1cd437f63dd256ae2ab3b7e9016257326cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=BA=D0=BE=D0=B2=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D0=9D=D0=B8=D0=BA=D0=B8=D1=82=D0=B0=20=D0=90=D0=BD=D0=B4=D1=80?= =?UTF-8?q?=D0=B5=D0=B5=D0=B2=D0=B8=D1=87?= Date: Fri, 7 Nov 2025 11:50:57 -0300 Subject: [PATCH 7/9] src,lib: refactor unsafe buffer creation to remove zero-fill toggle This removes the zero-fill toggle mechanism that allowed JavaScript to control ArrayBuffer initialization via shared memory. Instead, unsafe buffer creation now uses a dedicated C++ API. Refs: https://hackerone.com/reports/3405778 Co-Authored-By: Rafael Gonzaga Signed-off-by: RafaelGSS PR-URL: https://github.com/nodejs-private/node-private/pull/759 Reviewed-By: Matteo Collina CVE-ID: CVE-2025-55131 --- lib/internal/buffer.js | 33 +++----------- src/api/environment.cc | 9 +--- src/node_buffer.cc | 97 +++++++++++++++++++++++------------------- src/node_internals.h | 3 -- 4 files changed, 62 insertions(+), 80 deletions(-) diff --git a/lib/internal/buffer.js b/lib/internal/buffer.js index e0679f5306f89e..430fbe93ed400b 100644 --- a/lib/internal/buffer.js +++ b/lib/internal/buffer.js @@ -30,7 +30,7 @@ const { hexWrite, ucs2Write, utf8WriteStatic, - getZeroFillToggle, + createUnsafeArrayBuffer, } = internalBinding('buffer'); const { @@ -39,13 +39,6 @@ const { }, } = internalBinding('util'); -const { - namespace: { - isBuildingSnapshot, - }, - addAfterUserSerializeCallback, -} = require('internal/v8/startup_snapshot'); - // Temporary buffers to convert numbers. const float32Array = new Float32Array(1); const uInt8Float32Array = new Uint8Array(float32Array.buffer); @@ -1086,28 +1079,14 @@ function isMarkedAsUntransferable(obj) { return obj[untransferable_object_private_symbol] !== undefined; } -// A toggle used to access the zero fill setting of the array buffer allocator -// in C++. -// |zeroFill| can be undefined when running inside an isolate where we -// do not own the ArrayBuffer allocator. Zero fill is always on in that case. -let zeroFill; function createUnsafeBuffer(size) { - if (!zeroFill) { - zeroFill = getZeroFillToggle(); - if (isBuildingSnapshot()) { - // Reset the toggle so that after serialization, we'll re-create a real - // toggle connected to the C++ one via getZeroFillToggle(). - addAfterUserSerializeCallback(() => { - zeroFill = undefined; - }); - } - } - zeroFill[0] = 0; - try { + if (size <= 64) { + // Allocated in heap, doesn't call backing store anyway + // This is the same that the old impl did implicitly, but explicit now return new FastBuffer(size); - } finally { - zeroFill[0] = 1; } + + return new FastBuffer(createUnsafeArrayBuffer(size)); } module.exports = { diff --git a/src/api/environment.cc b/src/api/environment.cc index 710e015fa7bda3..14a2c49869a8d4 100644 --- a/src/api/environment.cc +++ b/src/api/environment.cc @@ -113,13 +113,8 @@ MaybeLocal PrepareStackTraceCallback(Local context, void* NodeArrayBufferAllocator::Allocate(size_t size) { void* ret; - if (zero_fill_field_ || per_process::cli_options->zero_fill_all_buffers) { - COUNT_GENERIC_USAGE("NodeArrayBufferAllocator.Allocate.ZeroFilled"); - ret = allocator_->Allocate(size); - } else { - COUNT_GENERIC_USAGE("NodeArrayBufferAllocator.Allocate.Uninitialized"); - ret = allocator_->AllocateUninitialized(size); - } + COUNT_GENERIC_USAGE("NodeArrayBufferAllocator.Allocate.ZeroFilled"); + ret = allocator_->Allocate(size); if (ret != nullptr) [[likely]] { total_mem_usage_.fetch_add(size, std::memory_order_relaxed); } diff --git a/src/node_buffer.cc b/src/node_buffer.cc index cc418017f76803..49df0b4284748e 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -80,7 +80,6 @@ using v8::Object; using v8::SharedArrayBuffer; using v8::String; using v8::Uint32; -using v8::Uint32Array; using v8::Uint8Array; using v8::Value; @@ -1244,45 +1243,6 @@ void SetBufferPrototype(const FunctionCallbackInfo& args) { realm->set_buffer_prototype_object(proto); } -void GetZeroFillToggle(const FunctionCallbackInfo& args) { - Environment* env = Environment::GetCurrent(args); - NodeArrayBufferAllocator* allocator = env->isolate_data()->node_allocator(); - Local ab; - // It can be a nullptr when running inside an isolate where we - // do not own the ArrayBuffer allocator. - if (allocator == nullptr || env->isolate_data()->is_building_snapshot()) { - // Create a dummy Uint32Array - the JS land can only toggle the C++ land - // setting when the allocator uses our toggle. With this the toggle in JS - // land results in no-ops. - // When building a snapshot, just use a dummy toggle as well to avoid - // introducing the dynamic external reference. We'll re-initialize the - // toggle with a real one connected to the C++ allocator after snapshot - // deserialization. - - ab = ArrayBuffer::New(env->isolate(), sizeof(uint32_t)); - } else { - // TODO(joyeecheung): save ab->GetBackingStore()->Data() in the Node.js - // array buffer allocator and include it into the C++ toggle while the - // Environment is still alive. - uint32_t* zero_fill_field = allocator->zero_fill_field(); - std::unique_ptr backing = - ArrayBuffer::NewBackingStore(zero_fill_field, - sizeof(*zero_fill_field), - [](void*, size_t, void*) {}, - nullptr); - ab = ArrayBuffer::New(env->isolate(), std::move(backing)); - } - - if (ab->SetPrivate(env->context(), - env->untransferable_object_private_symbol(), - True(env->isolate())) - .IsNothing()) { - return; - } - - args.GetReturnValue().Set(Uint32Array::New(ab, 0, 1)); -} - static void Btoa(const FunctionCallbackInfo& args) { CHECK_EQ(args.Length(), 1); Environment* env = Environment::GetCurrent(args); @@ -1449,6 +1409,57 @@ void CopyArrayBuffer(const FunctionCallbackInfo& args) { memcpy(dest, src, bytes_to_copy); } +// Converts a number parameter to size_t suitable for ArrayBuffer sizes +// Could be larger than uint32_t +// See v8::internal::TryNumberToSize and v8::internal::NumberToSize +inline size_t CheckNumberToSize(Local number) { + CHECK(number->IsNumber()); + double value = number.As()->Value(); + // See v8::internal::TryNumberToSize on this (and on < comparison) + double maxSize = static_cast(std::numeric_limits::max()); + CHECK(value >= 0 && value < maxSize); + size_t size = static_cast(value); +#ifdef V8_ENABLE_SANDBOX + CHECK_LE(size, kMaxSafeBufferSizeForSandbox); +#endif + return size; +} + +void CreateUnsafeArrayBuffer(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + if (args.Length() != 1) { + env->ThrowRangeError("Invalid array buffer length"); + return; + } + + size_t size = CheckNumberToSize(args[0]); + + Isolate* isolate = env->isolate(); + + Local buf; + + // 0-length, or zero-fill flag is set, or building snapshot + if (size == 0 || per_process::cli_options->zero_fill_all_buffers || + env->isolate_data()->is_building_snapshot()) { + buf = ArrayBuffer::New(isolate, size); + } else { + std::unique_ptr store = ArrayBuffer::NewBackingStore( + isolate, + size, + BackingStoreInitializationMode::kUninitialized, + v8::BackingStoreOnFailureMode::kReturnNull); + + if (!store) { + env->ThrowRangeError("Array buffer allocation failed"); + return; + } + + buf = ArrayBuffer::New(isolate, std::move(store)); + } + + args.GetReturnValue().Set(buf); +} + template uint32_t WriteOneByteString(const char* src, uint32_t src_len, @@ -1576,6 +1587,8 @@ void Initialize(Local target, SetMethodNoSideEffect(context, target, "indexOfString", IndexOfString); SetMethod(context, target, "copyArrayBuffer", CopyArrayBuffer); + SetMethodNoSideEffect( + context, target, "createUnsafeArrayBuffer", CreateUnsafeArrayBuffer); SetMethod(context, target, "swap16", Swap16); SetMethod(context, target, "swap32", Swap32); @@ -1625,8 +1638,6 @@ void Initialize(Local target, "utf8WriteStatic", SlowWriteString, &fast_write_string_utf8); - - SetMethod(context, target, "getZeroFillToggle", GetZeroFillToggle); } } // anonymous namespace @@ -1675,9 +1686,9 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(StringWrite); registry->Register(StringWrite); registry->Register(StringWrite); - registry->Register(GetZeroFillToggle); registry->Register(CopyArrayBuffer); + registry->Register(CreateUnsafeArrayBuffer); registry->Register(Atob); registry->Register(Btoa); diff --git a/src/node_internals.h b/src/node_internals.h index 6ec17b35cae6c6..2fd7975a680962 100644 --- a/src/node_internals.h +++ b/src/node_internals.h @@ -124,8 +124,6 @@ v8::MaybeLocal InitializePrivateSymbols( class NodeArrayBufferAllocator : public ArrayBufferAllocator { public: - inline uint32_t* zero_fill_field() { return &zero_fill_field_; } - void* Allocate(size_t size) override; // Defined in src/node.cc void* AllocateUninitialized(size_t size) override; void Free(void* data, size_t size) override; @@ -142,7 +140,6 @@ class NodeArrayBufferAllocator : public ArrayBufferAllocator { } private: - uint32_t zero_fill_field_ = 1; // Boolean but exposed as uint32 to JS land. std::atomic total_mem_usage_ {0}; // Delegate to V8's allocator for compatibility with the V8 memory cage. From daeafc06f96a640d675bf10dee96e152803ecb88 Mon Sep 17 00:00:00 2001 From: Stewart X Addison Date: Tue, 6 Jan 2026 15:49:25 +0000 Subject: [PATCH 8/9] src: use node- prefix on thread names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-URL: https://github.com/nodejs/node/pull/61307 Reviewed-By: Matteo Collina Reviewed-By: Stephen Belanger Reviewed-By: Gerhard Stöbich Reviewed-By: Anna Henningsen Reviewed-By: Colin Ihrig Reviewed-By: Richard Lau Reviewed-By: Luigi Pinca Reviewed-By: Rafael Gonzaga --- src/node.cc | 2 +- src/node_platform.cc | 2 +- test/addons/uv-thread-name/test.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node.cc b/src/node.cc index 8367227dd56ed4..39505548c45891 100644 --- a/src/node.cc +++ b/src/node.cc @@ -1234,7 +1234,7 @@ InitializeOncePerProcessInternal(const std::vector& args, } if (!(flags & ProcessInitializationFlags::kNoInitializeNodeV8Platform)) { - uv_thread_setname("MainThread"); + uv_thread_setname("node-MainThread"); per_process::v8_platform.Initialize( static_cast(per_process::cli_options->v8_thread_pool_size)); result->platform_ = per_process::v8_platform.Platform(); diff --git a/src/node_platform.cc b/src/node_platform.cc index 4815e8acd99c2f..197102068b74f4 100644 --- a/src/node_platform.cc +++ b/src/node_platform.cc @@ -49,7 +49,7 @@ static void PrintSourceLocation(const v8::SourceLocation& location) { } static void PlatformWorkerThread(void* data) { - uv_thread_setname("V8Worker"); + uv_thread_setname("node-V8Worker"); std::unique_ptr worker_data(static_cast(data)); diff --git a/test/addons/uv-thread-name/test.js b/test/addons/uv-thread-name/test.js index e5e2b949502d37..be92fc2ab5dbf6 100644 --- a/test/addons/uv-thread-name/test.js +++ b/test/addons/uv-thread-name/test.js @@ -12,7 +12,7 @@ const bindingPath = require.resolve(`./build/${common.buildType}/binding`); const binding = require(bindingPath); if (isMainThread) { - assert.strictEqual(binding.getThreadName(), 'MainThread'); + assert.strictEqual(binding.getThreadName(), 'node-MainThread'); const worker = new Worker(__filename); worker.on('message', common.mustCall((data) => { From b5bda89aa5ae94a783e1585ed3f4f0717e29ecc1 Mon Sep 17 00:00:00 2001 From: Hardanish Singh <61027578+Hardanish-Singh@users.noreply.github.com> Date: Tue, 13 Jan 2026 12:26:33 -0500 Subject: [PATCH 9/9] doc: clean up writing-and-running-benchmarks.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-URL: https://github.com/nodejs/node/pull/61345 Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca Reviewed-By: Matteo Collina Reviewed-By: Rafael Gonzaga Reviewed-By: Ulises Gascón --- .../writing-and-running-benchmarks.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/doc/contributing/writing-and-running-benchmarks.md b/doc/contributing/writing-and-running-benchmarks.md index ce95fc504e78c1..ff108e1088f5ae 100644 --- a/doc/contributing/writing-and-running-benchmarks.md +++ b/doc/contributing/writing-and-running-benchmarks.md @@ -28,7 +28,7 @@ which need to be included in the global Windows `PATH`. If you are using Nix, all the required tools are already listed in the `benchmarkTools` argument of the `shell.nix` file, so you can skip those -prerequesites. +prerequisites. ### HTTP benchmark requirements @@ -47,16 +47,19 @@ By default, `wrk` will be used as the benchmarker. If it is not available, `autocannon` will be used in its place. When creating an HTTP benchmark, the benchmarker to be used should be specified by providing it as an argument: -`node benchmark/run.js --set benchmarker=autocannon http` - -`node benchmark/http/simple.js benchmarker=autocannon` +```bash +node benchmark/run.js --set benchmarker=autocannon http +node benchmark/http/simple.js benchmarker=autocannon +``` #### HTTPS benchmark requirements To run the `https` benchmarks, one of `autocannon` or `wrk` benchmarkers must be used. -`node benchmark/https/simple.js benchmarker=autocannon` +```bash +node benchmark/https/simple.js benchmarker=autocannon +``` #### HTTP/2 benchmark requirements @@ -64,7 +67,9 @@ To run the `http2` benchmarks, the `h2load` benchmarker must be used. The `h2load` tool is a component of the `nghttp2` project and may be installed from [nghttp2.org][] or built from source. -`node benchmark/http2/simple.js benchmarker=h2load` +```bash +node benchmark/http2/simple.js benchmarker=h2load +``` ### Benchmark analysis requirements