Version in base suite: 18.20.4+dfsg-1~deb12u1 Base version: nodejs_18.20.4+dfsg-1~deb12u1 Target version: nodejs_18.20.4+dfsg-1~deb12u2 Base file: /srv/ftp-master.debian.org/ftp/pool/main/n/nodejs/nodejs_18.20.4+dfsg-1~deb12u1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/n/nodejs/nodejs_18.20.4+dfsg-1~deb12u2.dsc changelog | 87 +++++ patches/CVE-2025-23085.patch | 387 +++++++++++++++++++++++ patches/CVE-2025-23166.patch | 545 ++++++++++++++++++++++++++++++++ patches/CVE-2025-55131.patch | 283 +++++++++++++++++ patches/CVE-2025-59465.patch | 40 ++ patches/CVE-2025-59466.patch | 472 ++++++++++++++++++++++++++++ patches/CVE-2026-21637.patch | 613 +++++++++++++++++++++++++++++++++++++ patches/CVE-2026-21637_post1.patch | 171 ++++++++++ patches/CVE-2026-21710.patch | 147 ++++++++ patches/CVE-2026-21713.patch | 31 + patches/CVE-2026-21714.patch | 123 +++++++ patches/series | 14 salsa-ci.yml | 4 13 files changed, 2913 insertions(+), 4 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpa7owxp2f/nodejs_18.20.4+dfsg-1~deb12u1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmpa7owxp2f/nodejs_18.20.4+dfsg-1~deb12u2.dsc: no acceptable signature found diff -Nru nodejs-18.20.4+dfsg/debian/changelog nodejs-18.20.4+dfsg/debian/changelog --- nodejs-18.20.4+dfsg/debian/changelog 2024-07-09 15:36:33.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/changelog 2026-04-06 14:18:52.000000000 +0000 @@ -1,3 +1,90 @@ +nodejs (18.20.4+dfsg-1~deb12u2) bookworm-security; urgency=medium + + * Team upload + * Fix CVE-2025-23085: + A memory leak could occur when a remote peer abruptly closes + the socket without sending a GOAWAY notification. Additionally, + if an invalid header was detected by nghttp2, causing the + connection to be terminated by the peer, the same leak was + triggered. This flaw could lead to increased memory consumption + and potential denial of service under certain conditions + (Closes: #1094134) + * Fix CVE-2025-23166: + The C++ method SignTraits::DeriveBits() may incorrectly call + ThrowException() based on user-supplied inputs when executing + in a background thread, crashing the Node.js process. + Such cryptographic operations are commonly applied to + untrusted inputs. Thus, this mechanism potentially allows + an adversary to remotely crash a Node.js runtime. + (Closes: #1105832) + * Fix CVE-2025-55131: + A flaw in Node.js's buffer allocation logic can expose uninitialized + memory when allocations are interrupted, when using the `vm` module + with the timeout option. Under specific timing conditions, buffers + allocated with `Buffer.alloc` and other `TypedArray` instances like + `Uint8Array` may contain leftover data from previous operations, + allowing in-process secrets like tokens or passwords to leak or + causing data corruption. While exploitation typically requires precise + timing or in-process code execution, it can become remotely + exploitable when untrusted input influences workload and timeouts, + leading to potential confidentiality and integrity impact. + * Fix CVE-2025-59465: + A malformed `HTTP/2 HEADERS` frame with oversized, invalid + `HPACK` data can cause Node.js to crash by triggering an + unhandled `TLSSocket` error `ECONNRESET`. Instead of safely + closing the connection, the process crashes, enabling a remote + denial of service. This primarily affects applications that + do not attach explicit error handlers to secure sockets, + for example: ``` server.on('secureConnection', socket => + { socket.on('error', err => { console.log(err) }) }) ``` + * Fix CVE-2025-59466: + async_hooks would cause stack overflow + exceptions to exit with code 7 (kExceptionInFatalExceptionHandler) + instead of being catchable. + 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. + * Fix CVE-2025-23166: + A flaw in Node.js TLS error handling allows remote attackers to crash + or exhaust resources of a TLS server when `pskCallback` or + `ALPNCallback` are in use. Synchronous exceptions thrown during these + callbacks bypass standard TLS error handling paths (tlsClientError and + error), causing either immediate process termination or silent file + descriptor leaks that eventually lead to denial of service. Because + these callbacks process attacker-controlled input during the TLS + handshake, a remote client can repeatedly trigger the issue. This + vulnerability affects TLS servers using PSK or ALPN callbacks across. + * Fix CVE-2026-21710: + A flaw in Node.js HTTP request handling causes an uncaught `TypeError` + when a request is received with a header named `__proto__` and the + application accesses `req.headersDistinct`. When this occurs, + `dest["__proto__"]` resolves to `Object.prototype` rather than + `undefined`, causing `.push()` to be called on a non-array. This + exception is thrown synchronously inside a property getter and cannot + be intercepted by `error` event listeners, meaning it cannot be + handled without wrapping every `req.headersDistinct` access in a + `try/catch` + * Fix CVE-2026-21713: + A flaw in Node.js HMAC verification uses a non-constant-time + comparison when validating user-provided signatures, potentially + leaking timing information proportional to the number of matching + bytes. Under certain threat models where high-resolution timing + measurements are possible, this behavior could be exploited as a + timing oracle to infer HMAC values. Node.js already provides + timing-safe comparison primitives used elsewhere in the codebase, + indicating this is an oversight rather than an intentional design + decision. + * Fix CVE-2026-21714: + A memory leak occurs in Node.js HTTP/2 servers when a client sends + WINDOW_UPDATE frames on stream 0 (connection-level) that cause the + flow control window to exceed the maximum value of 2³¹-1. The server + correctly sends a GOAWAY frame, but the Http2Session object is never + cleaned up. + + -- Bastien Roucariès Mon, 06 Apr 2026 16:18:52 +0200 + nodejs (18.20.4+dfsg-1~deb12u1) bookworm-security; urgency=medium * New upstream version 18.20.4+dfsg. Closes: #1074047. diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23085.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23085.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23085.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23085.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,387 @@ +From: RafaelGSS +Date: Tue, 17 Dec 2024 16:58:03 -0300 +Subject: src: fix HTTP2 mem leak on premature close and ERR_PROTO + +This commit fixes a memory leak when the socket is +suddenly closed by the peer (without GOAWAY notification) +and when invalid header (by nghttp2) is identified and the +connection is terminated by peer. + +Refs: https://hackerone.com/reports/2841362 +PR-URL: https://github.com/nodejs-private/node-private/pull/650 +Reviewed-By: James M Snell +CVE-ID: CVE-2025-23085 +origin: https://github.com/nodejs/node/commit/6cc8d58e6f97c37c228f134bd9b98246c8871fb1 +--- + lib/internal/http2/core.js | 15 +++- + src/node_http2.cc | 36 +++++++-- + ...-http2-connect-method-extended-cant-turn-off.js | 6 ++ + test/parallel/test-http2-invalid-last-stream-id.js | 77 +++++++++++++++++++ + .../test-http2-options-max-headers-block-length.js | 4 +- + ...st-http2-options-max-headers-exceeds-nghttp2.js | 4 +- + test/parallel/test-http2-premature-close.js | 88 ++++++++++++++++++++++ + 7 files changed, 220 insertions(+), 10 deletions(-) + create mode 100644 test/parallel/test-http2-invalid-last-stream-id.js + create mode 100644 test/parallel/test-http2-premature-close.js + +diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js +index 92ce193..38844d3 100644 +--- a/lib/internal/http2/core.js ++++ b/lib/internal/http2/core.js +@@ -608,11 +608,20 @@ function onFrameError(id, type, code) { + return; + debugSessionObj(session, 'error sending frame type %d on stream %d, code: %d', + type, id, code); +- const emitter = session[kState].streams.get(id) || session; ++ ++ const stream = session[kState].streams.get(id); ++ const emitter = stream || session; + emitter[kUpdateTimer](); + emitter.emit('frameError', type, code, id); +- session[kState].streams.get(id).close(code); +- session.close(); ++ ++ // When a frameError happens is not uncommon that a pending GOAWAY ++ // package from nghttp2 is on flight with a correct error code. ++ // We schedule it using setImmediate to give some time for that ++ // package to arrive. ++ setImmediate(() => { ++ stream?.close(code); ++ session.close(); ++ }); + } + + function onAltSvc(stream, origin, alt) { +diff --git a/src/node_http2.cc b/src/node_http2.cc +index eb3506f..38d47f0 100644 +--- a/src/node_http2.cc ++++ b/src/node_http2.cc +@@ -750,6 +750,7 @@ bool Http2Session::CanAddStream() { + } + + void Http2Session::AddStream(Http2Stream* stream) { ++ Debug(this, "Adding stream: %d", stream->id()); + CHECK_GE(++statistics_.stream_count, 0); + streams_[stream->id()] = BaseObjectPtr(stream); + size_t size = streams_.size(); +@@ -760,6 +761,7 @@ void Http2Session::AddStream(Http2Stream* stream) { + + + BaseObjectPtr Http2Session::RemoveStream(int32_t id) { ++ Debug(this, "Removing stream: %d", id); + BaseObjectPtr stream; + if (streams_.empty()) + return stream; +@@ -936,6 +938,7 @@ int Http2Session::OnHeaderCallback(nghttp2_session* handle, + if (UNLIKELY(!stream)) + return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; + ++ Debug(session, "handling header key/pair for stream %d", id); + // If the stream has already been destroyed, ignore. + if (!stream->is_destroyed() && !stream->AddHeader(name, value, flags)) { + // This will only happen if the connected peer sends us more +@@ -1005,9 +1008,21 @@ int Http2Session::OnInvalidFrame(nghttp2_session* handle, + return 1; + } + +- // If the error is fatal or if error code is ERR_STREAM_CLOSED... emit error ++ // If the error is fatal or if error code is one of the following ++ // we emit and error: ++ // ++ // ERR_STREAM_CLOSED: An invalid frame has been received in a closed stream. ++ // ++ // ERR_PROTO: The RFC 7540 specifies: ++ // "An endpoint that encounters a connection error SHOULD first send a GOAWAY ++ // frame (Section 6.8) with the stream identifier of the last stream that it ++ // successfully received from its peer. ++ // The GOAWAY frame includes an error code that indicates the type of error" ++ // The GOAWAY frame is already sent by nghttp2. We emit the error ++ // to liberate the Http2Session to destroy. + if (nghttp2_is_fatal(lib_error_code) || +- lib_error_code == NGHTTP2_ERR_STREAM_CLOSED) { ++ lib_error_code == NGHTTP2_ERR_STREAM_CLOSED || ++ lib_error_code == NGHTTP2_ERR_PROTO) { + Environment* env = session->env(); + Isolate* isolate = env->isolate(); + HandleScope scope(isolate); +@@ -1070,7 +1085,6 @@ int Http2Session::OnFrameNotSent(nghttp2_session* handle, + Debug(session, "frame type %d was not sent, code: %d", + frame->hd.type, error_code); + +- // Do not report if the frame was not sent due to the session closing + if (error_code == NGHTTP2_ERR_SESSION_CLOSING || + error_code == NGHTTP2_ERR_STREAM_CLOSED || + error_code == NGHTTP2_ERR_STREAM_CLOSING) { +@@ -1079,7 +1093,15 @@ int Http2Session::OnFrameNotSent(nghttp2_session* handle, + // to destroy the session completely. + // Further information see: https://github.com/nodejs/node/issues/35233 + session->DecrefHeaders(frame); +- return 0; ++ // Currently, nghttp2 doesn't not inform us when is the best ++ // time to call session.close(). It relies on a closing connection ++ // from peer. If that doesn't happen, the nghttp2_session will be ++ // closed but the Http2Session will still be up causing a memory leak. ++ // Therefore, if the GOAWAY frame couldn't be send due to ++ // ERR_SESSION_CLOSING we should force close from our side. ++ if (frame->hd.type != 0x03) { ++ return 0; ++ } + } + + Isolate* isolate = env->isolate(); +@@ -1145,12 +1167,15 @@ int Http2Session::OnStreamClose(nghttp2_session* handle, + // ignore these. If this callback was not provided, nghttp2 would handle + // invalid headers strictly and would shut down the stream. We are intentionally + // being more lenient here although we may want to revisit this choice later. +-int Http2Session::OnInvalidHeader(nghttp2_session* session, ++int Http2Session::OnInvalidHeader(nghttp2_session* handle, + const nghttp2_frame* frame, + nghttp2_rcbuf* name, + nghttp2_rcbuf* value, + uint8_t flags, + void* user_data) { ++ Http2Session* session = static_cast(user_data); ++ int32_t id = GetFrameID(frame); ++ Debug(session, "invalid header received for stream %d", id); + // Ignore invalid header fields by default. + return 0; + } +@@ -1544,6 +1569,7 @@ void Http2Session::HandlePingFrame(const nghttp2_frame* frame) { + + // Called by OnFrameReceived when a complete SETTINGS frame has been received. + void Http2Session::HandleSettingsFrame(const nghttp2_frame* frame) { ++ Debug(this, "handling settings frame"); + bool ack = frame->hd.flags & NGHTTP2_FLAG_ACK; + if (!ack) { + js_fields_->bitfield &= ~(1 << kSessionRemoteSettingsIsUpToDate); +diff --git a/test/parallel/test-http2-connect-method-extended-cant-turn-off.js b/test/parallel/test-http2-connect-method-extended-cant-turn-off.js +index f4d033e..456aa1c 100644 +--- a/test/parallel/test-http2-connect-method-extended-cant-turn-off.js ++++ b/test/parallel/test-http2-connect-method-extended-cant-turn-off.js +@@ -27,4 +27,10 @@ server.listen(0, common.mustCall(() => { + server.close(); + })); + })); ++ ++ client.on('error', common.expectsError({ ++ code: 'ERR_HTTP2_ERROR', ++ name: 'Error', ++ message: 'Protocol error' ++ })); + })); +diff --git a/test/parallel/test-http2-invalid-last-stream-id.js b/test/parallel/test-http2-invalid-last-stream-id.js +new file mode 100644 +index 0000000..c6e4e78 +--- /dev/null ++++ b/test/parallel/test-http2-invalid-last-stream-id.js +@@ -0,0 +1,77 @@ ++// Flags: --expose-internals ++'use strict'; ++ ++const common = require('../common'); ++if (!common.hasCrypto) common.skip('missing crypto'); ++ ++const h2 = require('http2'); ++const net = require('net'); ++const assert = require('assert'); ++const { ServerHttp2Session } = require('internal/http2/core'); ++ ++async function sendInvalidLastStreamId(server) { ++ const client = new net.Socket(); ++ ++ const address = server.address(); ++ if (!common.hasIPv6 && address.family === 'IPv6') { ++ // Necessary to pass CI running inside containers. ++ client.connect(address.port); ++ } else { ++ client.connect(address); ++ } ++ ++ client.on('connect', common.mustCall(function() { ++ // HTTP/2 preface ++ client.write(Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n', 'utf8')); ++ ++ // Empty SETTINGS frame ++ client.write(Buffer.from([0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])); ++ ++ // GOAWAY frame with custom debug message ++ const goAwayFrame = [ ++ 0x00, 0x00, 0x21, // Length: 33 bytes ++ 0x07, // Type: GOAWAY ++ 0x00, // Flags ++ 0x00, 0x00, 0x00, 0x00, // Stream ID: 0 ++ 0x00, 0x00, 0x00, 0x01, // Last Stream ID: 1 ++ 0x00, 0x00, 0x00, 0x00, // Error Code: 0 (No error) ++ ]; ++ ++ // Add the debug message ++ const debugMessage = 'client transport shutdown'; ++ const goAwayBuffer = Buffer.concat([ ++ Buffer.from(goAwayFrame), ++ Buffer.from(debugMessage, 'utf8'), ++ ]); ++ ++ client.write(goAwayBuffer); ++ client.destroy(); ++ })); ++} ++ ++const server = h2.createServer(); ++ ++server.on('error', common.mustNotCall()); ++ ++server.on( ++ 'sessionError', ++ common.mustCall((err, session) => { ++ // When destroying the session, on Windows, we would get ECONNRESET ++ // errors, make sure we take those into account in our tests. ++ if (err.code !== 'ECONNRESET') { ++ assert.strictEqual(err.code, 'ERR_HTTP2_ERROR'); ++ assert.strictEqual(err.name, 'Error'); ++ assert.strictEqual(err.message, 'Protocol error'); ++ assert.strictEqual(session instanceof ServerHttp2Session, true); ++ } ++ session.close(); ++ server.close(); ++ }), ++); ++ ++server.listen( ++ 0, ++ common.mustCall(async () => { ++ await sendInvalidLastStreamId(server); ++ }), ++); +diff --git a/test/parallel/test-http2-options-max-headers-block-length.js b/test/parallel/test-http2-options-max-headers-block-length.js +index af1cc6f..15b142a 100644 +--- a/test/parallel/test-http2-options-max-headers-block-length.js ++++ b/test/parallel/test-http2-options-max-headers-block-length.js +@@ -35,9 +35,11 @@ server.listen(0, common.mustCall(() => { + assert.strictEqual(code, h2.constants.NGHTTP2_FRAME_SIZE_ERROR); + })); + ++ // NGHTTP2 will automatically send the NGHTTP2_REFUSED_STREAM with ++ // the GOAWAY frame. + req.on('error', common.expectsError({ + code: 'ERR_HTTP2_STREAM_ERROR', + name: 'Error', +- message: 'Stream closed with error code NGHTTP2_FRAME_SIZE_ERROR' ++ message: 'Stream closed with error code NGHTTP2_REFUSED_STREAM' + })); + })); +diff --git a/test/parallel/test-http2-options-max-headers-exceeds-nghttp2.js b/test/parallel/test-http2-options-max-headers-exceeds-nghttp2.js +index df3aeff..7767dbb 100644 +--- a/test/parallel/test-http2-options-max-headers-exceeds-nghttp2.js ++++ b/test/parallel/test-http2-options-max-headers-exceeds-nghttp2.js +@@ -59,6 +59,9 @@ server.listen(0, common.mustCall(() => { + 'session', + common.mustCall((session) => { + assert.strictEqual(session instanceof ServerHttp2Session, true); ++ session.on('close', common.mustCall(() => { ++ server.close(); ++ })); + }), + ); + server.on( +@@ -80,7 +83,6 @@ server.listen(0, common.mustCall(() => { + assert.strictEqual(err.name, 'Error'); + assert.strictEqual(err.message, 'Session closed with error code 9'); + assert.strictEqual(session instanceof ServerHttp2Session, true); +- server.close(); + }), + ); + +diff --git a/test/parallel/test-http2-premature-close.js b/test/parallel/test-http2-premature-close.js +new file mode 100644 +index 0000000..a9b08f5 +--- /dev/null ++++ b/test/parallel/test-http2-premature-close.js +@@ -0,0 +1,88 @@ ++// Flags: --expose-internals ++'use strict'; ++ ++const common = require('../common'); ++if (!common.hasCrypto) common.skip('missing crypto'); ++ ++const h2 = require('http2'); ++const net = require('net'); ++ ++async function requestAndClose(server) { ++ const client = new net.Socket(); ++ ++ const address = server.address(); ++ if (!common.hasIPv6 && address.family === 'IPv6') { ++ // Necessary to pass CI running inside containers. ++ client.connect(address.port); ++ } else { ++ client.connect(address); ++ } ++ ++ client.on('connect', common.mustCall(function() { ++ // Send HTTP/2 Preface ++ client.write(Buffer.from('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n', 'utf8')); ++ ++ // Send a SETTINGS frame (empty payload) ++ client.write(Buffer.from([0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])); ++ ++ const streamId = 1; ++ // Send a valid HEADERS frame ++ const headersFrame = Buffer.concat([ ++ Buffer.from([ ++ 0x00, 0x00, 0x0c, // Length: 12 bytes ++ 0x01, // Type: HEADERS ++ 0x05, // Flags: END_HEADERS + END_STREAM ++ (streamId >> 24) & 0xFF, // Stream ID: high byte ++ (streamId >> 16) & 0xFF, ++ (streamId >> 8) & 0xFF, ++ streamId & 0xFF, // Stream ID: low byte ++ ]), ++ Buffer.from([ ++ 0x82, // Indexed Header Field Representation (Predefined ":method: GET") ++ 0x84, // Indexed Header Field Representation (Predefined ":path: /") ++ 0x86, // Indexed Header Field Representation (Predefined ":scheme: http") ++ 0x44, 0x0a, // Custom ":authority: localhost" ++ 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x68, 0x6f, 0x73, 0x74, ++ ]), ++ ]); ++ client.write(headersFrame); ++ ++ // Send a valid DATA frame ++ const dataFrame = Buffer.concat([ ++ Buffer.from([ ++ 0x00, 0x00, 0x05, // Length: 5 bytes ++ 0x00, // Type: DATA ++ 0x00, // Flags: No flags ++ (streamId >> 24) & 0xFF, // Stream ID: high byte ++ (streamId >> 16) & 0xFF, ++ (streamId >> 8) & 0xFF, ++ streamId & 0xFF, // Stream ID: low byte ++ ]), ++ Buffer.from('Hello', 'utf8'), // Data payload ++ ]); ++ client.write(dataFrame); ++ ++ // Does not wait for server to reply. Shutdown the socket ++ client.end(); ++ })); ++} ++ ++const server = h2.createServer(); ++ ++server.on('error', common.mustNotCall()); ++ ++server.on( ++ 'session', ++ common.mustCall((session) => { ++ session.on('close', common.mustCall(() => { ++ server.close(); ++ })); ++ }), ++); ++ ++server.listen( ++ 0, ++ common.mustCall(async () => { ++ await requestAndClose(server); ++ }), ++); diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23166.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23166.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23166.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2025-23166.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,545 @@ +From: RafaelGSS +Date: Mon, 12 May 2025 12:33:54 -0300 +Subject: src: fix error handling on async crypto operations +MIME-Version: 1.0 +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Fixes: https://hackerone.com/reports/2817648 +Co-Authored-By: Filip Skokan +Co-Authored-By: Tobias Nießen +Backport-PR-URL: https://github.com/nodejs-private/node-private/pull/688 +CVE-ID: CVE-2025-23166 +PR-URL: https://github.com/nodejs-private/node-private/pull/710 + +origin: backport, https://github.com/nodejs/node/commit/6c57465920cf1b981a63031e71b1e4a73bf9beaa +--- + src/crypto/crypto_dh.cc | 8 +++---- + src/crypto/crypto_dh.h | 8 +++---- + src/crypto/crypto_ec.cc | 3 ++- + src/crypto/crypto_ec.h | 8 +++---- + src/crypto/crypto_hash.cc | 8 +++---- + src/crypto/crypto_hash.h | 8 +++---- + src/crypto/crypto_hkdf.cc | 8 +++---- + src/crypto/crypto_hkdf.h | 8 +++---- + src/crypto/crypto_hmac.cc | 8 +++---- + src/crypto/crypto_hmac.h | 8 +++---- + src/crypto/crypto_pbkdf2.cc | 8 +++---- + src/crypto/crypto_pbkdf2.h | 8 +++---- + src/crypto/crypto_random.cc | 20 +++++++++--------- + src/crypto/crypto_random.h | 19 +++++++++-------- + src/crypto/crypto_scrypt.cc | 8 +++---- + src/crypto/crypto_scrypt.h | 8 +++---- + src/crypto/crypto_sig.cc | 29 +++++++++++++++----------- + src/crypto/crypto_sig.h | 8 +++---- + src/crypto/crypto_util.h | 3 ++- + test/parallel/test-crypto-async-sign-verify.js | 26 +++++++++++++++++++++++ + 20 files changed, 123 insertions(+), 89 deletions(-) + +diff --git a/src/crypto/crypto_dh.cc b/src/crypto/crypto_dh.cc +index dd69323..cf9cbc9 100644 +--- a/src/crypto/crypto_dh.cc ++++ b/src/crypto/crypto_dh.cc +@@ -705,10 +705,10 @@ Maybe DHBitsTraits::EncodeOutput( + return Just(!result->IsEmpty()); + } + +-bool DHBitsTraits::DeriveBits( +- Environment* env, +- const DHBitsConfig& params, +- ByteSource* out) { ++bool DHBitsTraits::DeriveBits(Environment* env, ++ const DHBitsConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + *out = StatelessDiffieHellmanThreadsafe( + params.private_key->GetAsymmetricKey(), + params.public_key->GetAsymmetricKey()); +diff --git a/src/crypto/crypto_dh.h b/src/crypto/crypto_dh.h +index ec12548..f7c4b67 100644 +--- a/src/crypto/crypto_dh.h ++++ b/src/crypto/crypto_dh.h +@@ -131,10 +131,10 @@ struct DHBitsTraits final { + unsigned int offset, + DHBitsConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const DHBitsConfig& params, +- ByteSource* out_); ++ static bool DeriveBits(Environment* env, ++ const DHBitsConfig& params, ++ ByteSource* out_, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc +index 3ccea99..d7c77d0 100644 +--- a/src/crypto/crypto_ec.cc ++++ b/src/crypto/crypto_ec.cc +@@ -484,7 +484,8 @@ Maybe ECDHBitsTraits::AdditionalConfig( + + bool ECDHBitsTraits::DeriveBits(Environment* env, + const ECDHBitsConfig& params, +- ByteSource* out) { ++ ByteSource* out, ++ CryptoJobMode mode) { + size_t len = 0; + ManagedEVPPKey m_privkey = params.private_->GetAsymmetricKey(); + ManagedEVPPKey m_pubkey = params.public_->GetAsymmetricKey(); +diff --git a/src/crypto/crypto_ec.h b/src/crypto/crypto_ec.h +index 9782ce0..8c955ec 100644 +--- a/src/crypto/crypto_ec.h ++++ b/src/crypto/crypto_ec.h +@@ -77,10 +77,10 @@ struct ECDHBitsTraits final { + unsigned int offset, + ECDHBitsConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const ECDHBitsConfig& params, +- ByteSource* out_); ++ static bool DeriveBits(Environment* env, ++ const ECDHBitsConfig& params, ++ ByteSource* out_, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_hash.cc b/src/crypto/crypto_hash.cc +index 3cb39d7..eb1d515 100644 +--- a/src/crypto/crypto_hash.cc ++++ b/src/crypto/crypto_hash.cc +@@ -282,10 +282,10 @@ Maybe HashTraits::AdditionalConfig( + return Just(true); + } + +-bool HashTraits::DeriveBits( +- Environment* env, +- const HashConfig& params, +- ByteSource* out) { ++bool HashTraits::DeriveBits(Environment* env, ++ const HashConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + EVPMDPointer ctx(EVP_MD_CTX_new()); + + if (UNLIKELY(!ctx || +diff --git a/src/crypto/crypto_hash.h b/src/crypto/crypto_hash.h +index 2d17c35..d673694 100644 +--- a/src/crypto/crypto_hash.h ++++ b/src/crypto/crypto_hash.h +@@ -68,10 +68,10 @@ struct HashTraits final { + unsigned int offset, + HashConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const HashConfig& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const HashConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_hkdf.cc b/src/crypto/crypto_hkdf.cc +index 7663dd6..896bcf2 100644 +--- a/src/crypto/crypto_hkdf.cc ++++ b/src/crypto/crypto_hkdf.cc +@@ -100,10 +100,10 @@ Maybe HKDFTraits::AdditionalConfig( + return Just(true); + } + +-bool HKDFTraits::DeriveBits( +- Environment* env, +- const HKDFConfig& params, +- ByteSource* out) { ++bool HKDFTraits::DeriveBits(Environment* env, ++ const HKDFConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + EVPKeyCtxPointer ctx = + EVPKeyCtxPointer(EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, nullptr)); + if (!ctx || !EVP_PKEY_derive_init(ctx.get()) || +diff --git a/src/crypto/crypto_hkdf.h b/src/crypto/crypto_hkdf.h +index c4a537c..acd2b67 100644 +--- a/src/crypto/crypto_hkdf.h ++++ b/src/crypto/crypto_hkdf.h +@@ -42,10 +42,10 @@ struct HKDFTraits final { + unsigned int offset, + HKDFConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const HKDFConfig& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const HKDFConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_hmac.cc b/src/crypto/crypto_hmac.cc +index a9bb00e..8173946 100644 +--- a/src/crypto/crypto_hmac.cc ++++ b/src/crypto/crypto_hmac.cc +@@ -222,10 +222,10 @@ Maybe HmacTraits::AdditionalConfig( + return Just(true); + } + +-bool HmacTraits::DeriveBits( +- Environment* env, +- const HmacConfig& params, +- ByteSource* out) { ++bool HmacTraits::DeriveBits(Environment* env, ++ const HmacConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + HMACCtxPointer ctx(HMAC_CTX_new()); + + if (!ctx || +diff --git a/src/crypto/crypto_hmac.h b/src/crypto/crypto_hmac.h +index c80cc36..dd490f0 100644 +--- a/src/crypto/crypto_hmac.h ++++ b/src/crypto/crypto_hmac.h +@@ -73,10 +73,10 @@ struct HmacTraits final { + unsigned int offset, + HmacConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const HmacConfig& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const HmacConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_pbkdf2.cc b/src/crypto/crypto_pbkdf2.cc +index 963d0db..f6d37da 100644 +--- a/src/crypto/crypto_pbkdf2.cc ++++ b/src/crypto/crypto_pbkdf2.cc +@@ -111,10 +111,10 @@ Maybe PBKDF2Traits::AdditionalConfig( + return Just(true); + } + +-bool PBKDF2Traits::DeriveBits( +- Environment* env, +- const PBKDF2Config& params, +- ByteSource* out) { ++bool PBKDF2Traits::DeriveBits(Environment* env, ++ const PBKDF2Config& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + ByteSource::Builder buf(params.length); + + // Both pass and salt may be zero length here. +diff --git a/src/crypto/crypto_pbkdf2.h b/src/crypto/crypto_pbkdf2.h +index 6fda7cd..11ffad7 100644 +--- a/src/crypto/crypto_pbkdf2.h ++++ b/src/crypto/crypto_pbkdf2.h +@@ -55,10 +55,10 @@ struct PBKDF2Traits final { + unsigned int offset, + PBKDF2Config* params); + +- static bool DeriveBits( +- Environment* env, +- const PBKDF2Config& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const PBKDF2Config& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_random.cc b/src/crypto/crypto_random.cc +index 9850104..527f6a8 100644 +--- a/src/crypto/crypto_random.cc ++++ b/src/crypto/crypto_random.cc +@@ -56,10 +56,10 @@ Maybe RandomBytesTraits::AdditionalConfig( + return Just(true); + } + +-bool RandomBytesTraits::DeriveBits( +- Environment* env, +- const RandomBytesConfig& params, +- ByteSource* unused) { ++bool RandomBytesTraits::DeriveBits(Environment* env, ++ const RandomBytesConfig& params, ++ ByteSource* unused, ++ CryptoJobMode mode) { + return CSPRNG(params.buffer, params.size).is_ok(); + } + +@@ -151,7 +151,8 @@ Maybe RandomPrimeTraits::AdditionalConfig( + + bool RandomPrimeTraits::DeriveBits(Environment* env, + const RandomPrimeConfig& params, +- ByteSource* unused) { ++ ByteSource* unused, ++ CryptoJobMode mode) { + // BN_generate_prime_ex() calls RAND_bytes_ex() internally. + // Make sure the CSPRNG is properly seeded. + CHECK(CSPRNG(nullptr, 0).is_ok()); +@@ -194,11 +195,10 @@ Maybe CheckPrimeTraits::AdditionalConfig( + return Just(true); + } + +-bool CheckPrimeTraits::DeriveBits( +- Environment* env, +- const CheckPrimeConfig& params, +- ByteSource* out) { +- ++bool CheckPrimeTraits::DeriveBits(Environment* env, ++ const CheckPrimeConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + BignumCtxPointer ctx(BN_CTX_new()); + + int ret = BN_is_prime_ex( +diff --git a/src/crypto/crypto_random.h b/src/crypto/crypto_random.h +index a2807ed..b673cbb 100644 +--- a/src/crypto/crypto_random.h ++++ b/src/crypto/crypto_random.h +@@ -32,10 +32,10 @@ struct RandomBytesTraits final { + unsigned int offset, + RandomBytesConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const RandomBytesConfig& params, +- ByteSource* out_); ++ static bool DeriveBits(Environment* env, ++ const RandomBytesConfig& params, ++ ByteSource* out_, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +@@ -72,7 +72,8 @@ struct RandomPrimeTraits final { + static bool DeriveBits( + Environment* env, + const RandomPrimeConfig& params, +- ByteSource* out_); ++ ByteSource* out_, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +@@ -105,10 +106,10 @@ struct CheckPrimeTraits final { + unsigned int offset, + CheckPrimeConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const CheckPrimeConfig& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const CheckPrimeConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_scrypt.cc b/src/crypto/crypto_scrypt.cc +index 4dae07f..99a6a0e 100644 +--- a/src/crypto/crypto_scrypt.cc ++++ b/src/crypto/crypto_scrypt.cc +@@ -114,10 +114,10 @@ Maybe ScryptTraits::AdditionalConfig( + return Just(true); + } + +-bool ScryptTraits::DeriveBits( +- Environment* env, +- const ScryptConfig& params, +- ByteSource* out) { ++bool ScryptTraits::DeriveBits(Environment* env, ++ const ScryptConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode) { + ByteSource::Builder buf(params.length); + + // Both the pass and salt may be zero-length at this point +diff --git a/src/crypto/crypto_scrypt.h b/src/crypto/crypto_scrypt.h +index 3d18563..9ea9d75 100644 +--- a/src/crypto/crypto_scrypt.h ++++ b/src/crypto/crypto_scrypt.h +@@ -57,10 +57,10 @@ struct ScryptTraits final { + unsigned int offset, + ScryptConfig* params); + +- static bool DeriveBits( +- Environment* env, +- const ScryptConfig& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const ScryptConfig& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_sig.cc b/src/crypto/crypto_sig.cc +index 64e788d..1881034 100644 +--- a/src/crypto/crypto_sig.cc ++++ b/src/crypto/crypto_sig.cc +@@ -695,12 +695,13 @@ Maybe SignTraits::AdditionalConfig( + return Just(true); + } + +-bool SignTraits::DeriveBits( +- Environment* env, +- const SignConfiguration& params, +- ByteSource* out) { +- ClearErrorOnReturn clear_error_on_return; ++bool SignTraits::DeriveBits(Environment* env, ++ const SignConfiguration& params, ++ ByteSource* out, ++ CryptoJobMode mode) { ++ bool can_throw = mode == CryptoJobMode::kCryptoJobSync; + EVPMDPointer context(EVP_MD_CTX_new()); ++ + EVP_PKEY_CTX* ctx = nullptr; + + switch (params.mode) { +@@ -711,7 +712,7 @@ bool SignTraits::DeriveBits( + params.digest, + nullptr, + params.key.get())) { +- crypto::CheckThrow(env, SignBase::Error::kSignInit); ++ if (can_throw) crypto::CheckThrow(env, SignBase::Error::kSignInit); + return false; + } + break; +@@ -722,7 +723,7 @@ bool SignTraits::DeriveBits( + params.digest, + nullptr, + params.key.get())) { +- crypto::CheckThrow(env, SignBase::Error::kSignInit); ++ if (can_throw) crypto::CheckThrow(env, SignBase::Error::kSignInit); + return false; + } + break; +@@ -740,7 +741,7 @@ bool SignTraits::DeriveBits( + ctx, + padding, + salt_length)) { +- crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); ++ if (can_throw) crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); + return false; + } + +@@ -754,7 +755,8 @@ bool SignTraits::DeriveBits( + &len, + params.data.data(), + params.data.size())) { +- crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); ++ if (can_throw) ++ crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); + return false; + } + ByteSource::Builder buf(len); +@@ -763,7 +765,8 @@ bool SignTraits::DeriveBits( + &len, + params.data.data(), + params.data.size())) { +- crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); ++ if (can_throw) ++ crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); + return false; + } + *out = std::move(buf).release(len); +@@ -774,13 +777,15 @@ bool SignTraits::DeriveBits( + params.data.data(), + params.data.size()) || + !EVP_DigestSignFinal(context.get(), nullptr, &len)) { +- crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); ++ if (can_throw) ++ crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); + return false; + } + ByteSource::Builder buf(len); + if (!EVP_DigestSignFinal( + context.get(), buf.data(), &len)) { +- crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); ++ if (can_throw) ++ crypto::CheckThrow(env, SignBase::Error::kSignPrivateKey); + return false; + } + +diff --git a/src/crypto/crypto_sig.h b/src/crypto/crypto_sig.h +index 1a4cda4..6b3c8d9 100644 +--- a/src/crypto/crypto_sig.h ++++ b/src/crypto/crypto_sig.h +@@ -147,10 +147,10 @@ struct SignTraits final { + unsigned int offset, + SignConfiguration* params); + +- static bool DeriveBits( +- Environment* env, +- const SignConfiguration& params, +- ByteSource* out); ++ static bool DeriveBits(Environment* env, ++ const SignConfiguration& params, ++ ByteSource* out, ++ CryptoJobMode mode); + + static v8::Maybe EncodeOutput( + Environment* env, +diff --git a/src/crypto/crypto_util.h b/src/crypto/crypto_util.h +index bf19334..e528de9 100644 +--- a/src/crypto/crypto_util.h ++++ b/src/crypto/crypto_util.h +@@ -498,9 +498,10 @@ class DeriveBitsJob final : public CryptoJob { + std::move(params)) {} + + void DoThreadPoolWork() override { ++ ClearErrorOnReturn clear_error_on_return; + if (!DeriveBitsTraits::DeriveBits( + AsyncWrap::env(), +- *CryptoJob::params(), &out_)) { ++ *CryptoJob::params(), &out_, this->mode())) { + CryptoErrorStore* errors = CryptoJob::errors(); + errors->Capture(); + if (errors->Empty()) +diff --git a/test/parallel/test-crypto-async-sign-verify.js b/test/parallel/test-crypto-async-sign-verify.js +index 4e3c32f..5924d36 100644 +--- a/test/parallel/test-crypto-async-sign-verify.js ++++ b/test/parallel/test-crypto-async-sign-verify.js +@@ -141,3 +141,29 @@ test('dsa_public.pem', 'dsa_private.pem', 'sha256', false, + }) + .catch(common.mustNotCall()); + } ++ ++{ ++ const untrustedKey = `-----BEGIN PUBLIC KEY----- ++MCowBQYDK2VuAyEA6pwGRbadNQAI/tYN8+/p/0/hbsdHfOEGr1ADiLVk/Gc= ++-----END PUBLIC KEY-----`; ++ const data = crypto.randomBytes(32); ++ const signature = crypto.randomBytes(16); ++ ++ const expected = common.hasOpenSSL3 ? ++ /operation not supported for this keytype/ : /no default digest/; ++ ++ crypto.verify(undefined, data, untrustedKey, signature, common.mustCall((err) => { ++ assert.ok(err); ++ assert.match(err.message, expected); ++ })); ++} ++ ++{ ++ const { privateKey } = crypto.generateKeyPairSync('rsa', { ++ modulusLength: 512 ++ }); ++ crypto.sign('sha512', 'message', privateKey, common.mustCall((err) => { ++ assert.ok(err); ++ assert.match(err.message, /digest too big for rsa key/); ++ })); ++} diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2025-55131.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2025-55131.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2025-55131.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2025-55131.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,283 @@ +From: ChALkeR Nikita Skovoroda +Date: Mon, 6 Apr 2026 16:13:34 +0200 +Subject: 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 +Co-Authored-By: Joyee Cheung +Signed-off-by: RafaelGSS +PR-URL: https://github.com/nodejs-private/node-private/pull/759 +Backport-PR-URL: https://github.com/nodejs-private/node-private/pull/799 +CVE-ID: CVE-2025-55131 + +origin: backport, https://github.com/nodejs/node/commit/51f4de4b4a52b5b0eb2c63ecbb4126577e05f636 +--- + deps/v8/include/v8-array-buffer.h | 7 +++ + deps/v8/src/api/api.cc | 17 +++++++ + lib/internal/buffer.js | 23 +++------- + lib/internal/process/pre_execution.js | 2 - + src/api/environment.cc | 3 +- + src/node_buffer.cc | 84 ++++++++++++++++++++++------------- + 6 files changed, 83 insertions(+), 53 deletions(-) + +diff --git a/deps/v8/include/v8-array-buffer.h b/deps/v8/include/v8-array-buffer.h +index cc5d2d4..bf1df3e 100644 +--- a/deps/v8/include/v8-array-buffer.h ++++ b/deps/v8/include/v8-array-buffer.h +@@ -223,6 +223,13 @@ class V8_EXPORT ArrayBuffer : public Object { + */ + static std::unique_ptr NewBackingStore(Isolate* isolate, + size_t byte_length); ++ /** ++ * Returns a new standalone BackingStore with uninitialized memory and ++ * return nullptr on failure. ++ * This variant is for not breaking ABI on Node.js LTS. DO NOT USE. ++ */ ++ static std::unique_ptr NewBackingStoreForNodeLTS( ++ Isolate* isolate, size_t byte_length); + /** + * Returns a new standalone BackingStore that takes over the ownership of + * the given buffer. The destructor of the BackingStore invokes the given +diff --git a/deps/v8/src/api/api.cc b/deps/v8/src/api/api.cc +index 3b1a816..6a2b501 100644 +--- a/deps/v8/src/api/api.cc ++++ b/deps/v8/src/api/api.cc +@@ -8014,6 +8014,23 @@ std::unique_ptr v8::ArrayBuffer::NewBackingStore( + static_cast(backing_store.release())); + } + ++std::unique_ptr v8::ArrayBuffer::NewBackingStoreForNodeLTS( ++ Isolate* v8_isolate, size_t byte_length) { ++ i::Isolate* i_isolate = reinterpret_cast(v8_isolate); ++ API_RCS_SCOPE(i_isolate, ArrayBuffer, NewBackingStore); ++ CHECK_LE(byte_length, i::JSArrayBuffer::kMaxByteLength); ++ ENTER_V8_NO_SCRIPT_NO_EXCEPTION(i_isolate); ++ std::unique_ptr backing_store = ++ i::BackingStore::Allocate(i_isolate, byte_length, ++ i::SharedFlag::kNotShared, ++ i::InitializedFlag::kUninitialized); ++ if (!backing_store) { ++ return nullptr; ++ } ++ return std::unique_ptr( ++ static_cast(backing_store.release())); ++} ++ + std::unique_ptr v8::ArrayBuffer::NewBackingStore( + void* data, size_t byte_length, v8::BackingStore::DeleterCallback deleter, + void* deleter_data) { +diff --git a/lib/internal/buffer.js b/lib/internal/buffer.js +index fbe9de2..23df382 100644 +--- a/lib/internal/buffer.js ++++ b/lib/internal/buffer.js +@@ -30,7 +30,7 @@ const { + hexWrite, + ucs2Write, + utf8Write, +- getZeroFillToggle, ++ createUnsafeArrayBuffer, + } = internalBinding('buffer'); + + const { +@@ -1053,26 +1053,14 @@ function markAsUntransferable(obj) { + obj[untransferable_object_private_symbol] = true; + } + +-// 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 = getZeroFillToggle(); + function createUnsafeBuffer(size) { +- 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; + } +-} + +-// The connection between the JS land zero fill toggle and the +-// C++ one in the NodeArrayBufferAllocator gets lost if the toggle +-// is deserialized from the snapshot, because V8 owns the underlying +-// memory of this toggle. This resets the connection. +-function reconnectZeroFillToggle() { +- zeroFill = getZeroFillToggle(); ++ return new FastBuffer(createUnsafeArrayBuffer(size)); + } + + module.exports = { +@@ -1082,5 +1070,4 @@ module.exports = { + createUnsafeBuffer, + readUInt16BE, + readUInt32BE, +- reconnectZeroFillToggle, + }; +diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js +index 4795be8..f95ab3d 100644 +--- a/lib/internal/process/pre_execution.js ++++ b/lib/internal/process/pre_execution.js +@@ -16,7 +16,6 @@ const { + getOptionValue, + refreshOptions, + } = require('internal/options'); +-const { reconnectZeroFillToggle } = require('internal/buffer'); + const { + defineOperation, + exposeInterface, +@@ -56,7 +55,6 @@ function prepareExecution(options) { + const { expandArgv1, initializeModules, isMainThread } = options; + + refreshRuntimeOptions(); +- reconnectZeroFillToggle(); + + // Patch the process object and get the resolved main entry point. + const mainEntry = patchProcessObject(expandArgv1); +diff --git a/src/api/environment.cc b/src/api/environment.cc +index de58a26..cbe7719 100644 +--- a/src/api/environment.cc ++++ b/src/api/environment.cc +@@ -104,8 +104,9 @@ void* NodeArrayBufferAllocator::Allocate(size_t size) { + ret = allocator_->Allocate(size); + else + ret = allocator_->AllocateUninitialized(size); +- if (LIKELY(ret != nullptr)) ++ if (ret != nullptr) [[likely]] { + total_mem_usage_.fetch_add(size, std::memory_order_relaxed); ++ } + return ret; + } + +diff --git a/src/node_buffer.cc b/src/node_buffer.cc +index 4bc7336..8d9da8d 100644 +--- a/src/node_buffer.cc ++++ b/src/node_buffer.cc +@@ -1261,35 +1261,6 @@ void SetBufferPrototype(const FunctionCallbackInfo& args) { + env->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) { +- // 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. +- ab = ArrayBuffer::New(env->isolate(), sizeof(uint32_t)); +- } else { +- 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)); +- } +- +- ab->SetPrivate( +- env->context(), +- env->untransferable_object_private_symbol(), +- True(env->isolate())).Check(); +- +- args.GetReturnValue().Set(Uint32Array::New(ab, 0, 1)); +-} +- + void DetachArrayBuffer(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + if (args[0]->IsArrayBuffer()) { +@@ -1357,6 +1328,54 @@ 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; ++ ++ NodeArrayBufferAllocator* allocator = env->isolate_data()->node_allocator(); ++ // 0-length, or zero-fill flag is set, or building snapshot ++ if (size == 0 || per_process::cli_options->zero_fill_all_buffers || ++ allocator == nullptr) { ++ buf = ArrayBuffer::New(isolate, size); ++ } else { ++ std::unique_ptr store = ++ ArrayBuffer::NewBackingStoreForNodeLTS(isolate, size); ++ if (!store) { ++ // This slightly differs from the old behavior, ++ // as in v8 that's a RangeError, and this is an Error with code ++ return env->ThrowRangeError("Array buffer allocation failed"); ++ } ++ buf = ArrayBuffer::New(isolate, std::move(store)); ++ } ++ ++ args.GetReturnValue().Set(buf); ++} ++ + void Initialize(Local target, + Local unused, + Local context, +@@ -1379,6 +1398,8 @@ void Initialize(Local target, + + SetMethod(context, target, "detachArrayBuffer", DetachArrayBuffer); + SetMethod(context, target, "copyArrayBuffer", CopyArrayBuffer); ++ SetMethodNoSideEffect( ++ context, target, "createUnsafeArrayBuffer", CreateUnsafeArrayBuffer); + + SetMethod(context, target, "swap16", Swap16); + SetMethod(context, target, "swap32", Swap32); +@@ -1418,8 +1439,6 @@ void Initialize(Local target, + SetMethod(context, target, "hexWrite", StringWrite); + SetMethod(context, target, "ucs2Write", StringWrite); + SetMethod(context, target, "utf8Write", StringWrite); +- +- SetMethod(context, target, "getZeroFillToggle", GetZeroFillToggle); + } + + } // anonymous namespace +@@ -1463,10 +1482,11 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { + registry->Register(StringWrite); + registry->Register(StringWrite); + registry->Register(StringWrite); +- registry->Register(GetZeroFillToggle); + + registry->Register(DetachArrayBuffer); + registry->Register(CopyArrayBuffer); ++ ++ registry->Register(CreateUnsafeArrayBuffer); + } + + } // namespace Buffer diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59465.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59465.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59465.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59465.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,40 @@ +From: RafaelGSS +Date: Fri, 31 Oct 2025 16:27:48 -0300 +Subject: 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/797 +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/_tls_wrap.js | 1 + + 1 file changed, 1 insertion(+) + +diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js +index 909f36d..d27bd80 100644 +--- a/lib/_tls_wrap.js ++++ b/lib/_tls_wrap.js +@@ -1234,6 +1234,7 @@ function tlsConnectionListener(rawSocket) { + socket[kErrorEmitted] = false; + socket.on('close', onSocketClose); + socket.on('_tlsError', onSocketTLSError); ++ socket.on('error', onSocketTLSError); + } + + // AUTHENTICATION MODES diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59466.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59466.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59466.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2025-59466.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,472 @@ +From: Matteo Collina +Date: Sun, 26 Apr 2026 17:21:57 +0200 +Subject: 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 +--- + src/async_wrap.cc | 9 ++- + src/node_errors.cc | 85 +++++++++++++++++++++- + src/node_errors.h | 2 +- + ...test-async-hooks-stack-overflow-nested-async.js | 80 ++++++++++++++++++++ + .../test-async-hooks-stack-overflow-try-catch.js | 47 ++++++++++++ + test/parallel/test-async-hooks-stack-overflow.js | 47 ++++++++++++ + ...ion-handler-stack-overflow-on-stack-overflow.js | 29 ++++++++ + ...st-uncaught-exception-handler-stack-overflow.js | 29 ++++++++ + 8 files changed, 322 insertions(+), 6 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 697eff0..44150e4 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 11bf2cc..f525ae3 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 cc33653..04aa3c7 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 0000000..779f8d7 +--- /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 0000000..4333890 +--- /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 0000000..aff4196 +--- /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 0000000..1923b7f +--- /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 0000000..050cd09 +--- /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}`); ++} diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,613 @@ +From: Matteo Collina +Date: Mon, 22 Dec 2025 18:25:33 +0100 +Subject: 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/796 +Reviewed-By: Matteo Collina +CVE-ID: CVE-2026-21637 + +origin: backport, https://github.com/nodejs/node/commit/85f73e7057e9badf6e7713f7440769375cdb5df5 +--- + lib/_tls_wrap.js | 157 +++++----- + test/parallel/test-tls-alpn-server-client.js | 33 +- + ...est-tls-psk-alpn-callback-exception-handling.js | 338 +++++++++++++++++++++ + 3 files changed, 447 insertions(+), 81 deletions(-) + create mode 100644 test/parallel/test-tls-psk-alpn-callback-exception-handling.js + +diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js +index d27bd80..1a4613b 100644 +--- a/lib/_tls_wrap.js ++++ b/lib/_tls_wrap.js +@@ -241,39 +241,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) { +@@ -380,63 +385,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 8e0ec8e..9853a1c 100644 +--- a/test/parallel/test-tls-alpn-server-client.js ++++ b/test/parallel/test-tls-alpn-server-client.js +@@ -204,24 +204,35 @@ 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, function(results) { +- // Callback returns 'http/5' => doesn't match client ALPN => error & reset +- assert.strictEqual(results[0].server, undefined); +- assert.ok(['ECONNRESET', 'ERR_SSL_TLSV1_ALERT_NO_APPLICATION_PROTOCOL'].includes(results[0].client.error.code)); +- +- TestALPNOptionsCallback(); +- }); ++ server.listen(0, serverIP, common.mustCall(() => { ++ const client = tls.connect({ ++ port: server.address().port, ++ host: serverIP, ++ rejectUnauthorized: false, ++ ALPNProtocols: ['http/1', 'h2'], ++ }, common.mustNotCall()); ++ ++ 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(); ++ })); ++ })); + } + + function 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 0000000..153853a +--- /dev/null ++++ b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js +@@ -0,0 +1,338 @@ ++'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() { ++ let resolve, reject; ++ const promise = new Promise((res, rej) => { ++ resolve = res; ++ reject = rej; ++ }); ++ 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', common.mustNotCall(() => { ++ 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', common.mustNotCall(() => { ++ 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', common.mustNotCall(() => { ++ 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', common.mustNotCall(() => { ++ 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', common.mustNotCall(() => { ++ 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', common.mustNotCall(() => { ++ 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; ++ }); ++}); diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637_post1.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637_post1.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637_post1.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21637_post1.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,171 @@ +From: Matteo Collina +Date: Tue, 17 Feb 2026 14:26:17 +0100 +Subject: tls: wrap SNICallback invocation in try/catch + +Wrap the owner._SNICallback() invocation in loadSNI() with try/catch +to route exceptions through owner.destroy() instead of letting them +become uncaught exceptions. This completes the fix from CVE-2026-21637 +which added try/catch protection to callALPNCallback, +onPskServerCallback, and onPskClientCallback but missed loadSNI(). + +Without this fix, a remote unauthenticated attacker can crash any +Node.js TLS server whose SNICallback may throw on unexpected input +by sending a single TLS ClientHello with a crafted server_name value. + +Fixes: https://hackerone.com/reports/3556769 +Refs: https://hackerone.com/reports/3473882 +CVE-ID: CVE-2026-21637 +PR-URL: https://github.com/nodejs-private/node-private/pull/839 +Reviewed-By: Rafael Gonzaga +CVE-ID: CVE-2026-21637 + +origin: https://github.com/nodejs/node/commit/cc3f294507c715908b2b31a5301e295b3de04152 +--- + lib/_tls_wrap.js | 30 ++++---- + ...est-tls-psk-alpn-callback-exception-handling.js | 90 ++++++++++++++++++++++ + 2 files changed, 107 insertions(+), 13 deletions(-) + +diff --git a/lib/_tls_wrap.js b/lib/_tls_wrap.js +index 1a4613b..1e4c699 100644 +--- a/lib/_tls_wrap.js ++++ b/lib/_tls_wrap.js +@@ -217,23 +217,27 @@ function loadSNI(info) { + return requestOCSP(owner, info); + + let once = false; +- owner._SNICallback(servername, (err, context) => { +- if (once) +- return owner.destroy(new ERR_MULTIPLE_CALLBACK()); +- once = true; ++ try { ++ owner._SNICallback(servername, (err, context) => { ++ if (once) ++ return owner.destroy(new ERR_MULTIPLE_CALLBACK()); ++ once = true; + +- if (err) +- return owner.destroy(err); ++ if (err) ++ return owner.destroy(err); + +- if (owner._handle === null) +- return owner.destroy(new ERR_SOCKET_CLOSED()); ++ if (owner._handle === null) ++ return owner.destroy(new ERR_SOCKET_CLOSED()); + +- // TODO(indutny): eventually disallow raw `SecureContext` +- if (context) +- owner._handle.sni_context = context.context || context; ++ // TODO(indutny): eventually disallow raw `SecureContext` ++ if (context) ++ owner._handle.sni_context = context.context || context; + +- requestOCSP(owner, info); +- }); ++ requestOCSP(owner, info); ++ }); ++ } catch (err) { ++ owner.destroy(err); ++ } + } + + +diff --git a/test/parallel/test-tls-psk-alpn-callback-exception-handling.js b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js +index 153853a..12d4ede 100644 +--- a/test/parallel/test-tls-psk-alpn-callback-exception-handling.js ++++ b/test/parallel/test-tls-psk-alpn-callback-exception-handling.js +@@ -335,4 +335,94 @@ describe('TLS callback exception handling', () => { + + await promise; + }); ++ ++ // Test 7: SNI callback throwing should emit tlsClientError ++ it('SNICallback throwing emits tlsClientError', async (t) => { ++ const server = tls.createServer({ ++ key: fixtures.readKey('agent2-key.pem'), ++ cert: fixtures.readKey('agent2-cert.pem'), ++ SNICallback: (servername, cb) => { ++ throw new Error('Intentional SNI 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 SNI 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', ++ servername: 'evil.attacker.com', ++ rejectUnauthorized: false, ++ }); ++ ++ client.on('error', () => {}); ++ ++ await promise; ++ }); ++ ++ // Test 8: SNI callback with validation error should emit tlsClientError ++ it('SNICallback validation error emits tlsClientError', async (t) => { ++ const server = tls.createServer({ ++ key: fixtures.readKey('agent2-key.pem'), ++ cert: fixtures.readKey('agent2-cert.pem'), ++ SNICallback: (servername, cb) => { ++ // Simulate common developer pattern: throw on unknown servername ++ if (servername !== 'expected.example.com') { ++ throw new Error(`Unknown servername: ${servername}`); ++ } ++ cb(null, null); ++ }, ++ }); ++ ++ t.after(() => server.close()); ++ ++ const { promise, resolve, reject } = createTestPromise(); ++ ++ server.on('tlsClientError', common.mustCall((err, socket) => { ++ try { ++ assert.ok(err instanceof Error); ++ assert.ok(err.message.includes('Unknown servername')); ++ 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', ++ servername: 'unexpected.domain.com', ++ rejectUnauthorized: false, ++ }); ++ ++ client.on('error', () => {}); ++ ++ await promise; ++ }); + }); diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21710.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21710.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21710.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21710.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,147 @@ +From: Matteo Collina +Date: Thu, 19 Feb 2026 15:49:43 +0100 +Subject: http: use null prototype for headersDistinct/trailersDistinct + +Use { __proto__: null } instead of {} when initializing the +headersDistinct and trailersDistinct destination objects. + +A plain {} inherits from Object.prototype, so when a __proto__ +header is received, dest["__proto__"] resolves to Object.prototype +(truthy), causing _addHeaderLineDistinct to call .push() on it, +which throws an uncaught TypeError and crashes the process. + +Ref: https://hackerone.com/reports/3560402 +PR-URL: https://github.com/nodejs-private/node-private/pull/821 +Refs: https://hackerone.com/reports/3560402 +Reviewed-By: Marco Ippolito +Reviewed-By: Rafael Gonzaga +CVE-ID: CVE-2026-21710 +origin: https://github.com/nodejs/node/commit/00ad47a28eb2e3dc0ff5610d58c53341acf3cf8d +--- + lib/_http_incoming.js | 4 +-- + test/parallel/test-http-headers-distinct-proto.js | 36 +++++++++++++++++++++++ + test/parallel/test-http-multiple-headers.js | 16 +++++----- + 3 files changed, 46 insertions(+), 10 deletions(-) + create mode 100644 test/parallel/test-http-headers-distinct-proto.js + +diff --git a/lib/_http_incoming.js b/lib/_http_incoming.js +index e45ae81..77433e5 100644 +--- a/lib/_http_incoming.js ++++ b/lib/_http_incoming.js +@@ -131,7 +131,7 @@ ObjectDefineProperty(IncomingMessage.prototype, 'headersDistinct', { + __proto__: null, + get: function() { + if (!this[kHeadersDistinct]) { +- this[kHeadersDistinct] = {}; ++ this[kHeadersDistinct] = { __proto__: null }; + + const src = this.rawHeaders; + const dst = this[kHeadersDistinct]; +@@ -171,7 +171,7 @@ ObjectDefineProperty(IncomingMessage.prototype, 'trailersDistinct', { + __proto__: null, + get: function() { + if (!this[kTrailersDistinct]) { +- this[kTrailersDistinct] = {}; ++ this[kTrailersDistinct] = { __proto__: null }; + + const src = this.rawTrailers; + const dst = this[kTrailersDistinct]; +diff --git a/test/parallel/test-http-headers-distinct-proto.js b/test/parallel/test-http-headers-distinct-proto.js +new file mode 100644 +index 0000000..bd4cb82 +--- /dev/null ++++ b/test/parallel/test-http-headers-distinct-proto.js +@@ -0,0 +1,36 @@ ++'use strict'; ++ ++const common = require('../common'); ++const assert = require('assert'); ++const http = require('http'); ++const net = require('net'); ++ ++// Regression test: sending a __proto__ header must not crash the server ++// when accessing req.headersDistinct or req.trailersDistinct. ++ ++const server = http.createServer(common.mustCall((req, res) => { ++ const headers = req.headersDistinct; ++ assert.strictEqual(Object.getPrototypeOf(headers), null); ++ assert.deepStrictEqual(Object.getOwnPropertyDescriptor(headers, '__proto__').value, ['test']); ++ res.end(); ++})); ++ ++server.listen(0, common.mustCall(() => { ++ const port = server.address().port; ++ ++ const client = net.connect(port, common.mustCall(() => { ++ client.write( ++ 'GET / HTTP/1.1\r\n' + ++ 'Host: localhost\r\n' + ++ '__proto__: test\r\n' + ++ 'Connection: close\r\n' + ++ '\r\n', ++ ); ++ })); ++ ++ client.on('end', common.mustCall(() => { ++ server.close(); ++ })); ++ ++ client.resume(); ++})); +diff --git a/test/parallel/test-http-multiple-headers.js b/test/parallel/test-http-multiple-headers.js +index 1ebd290..1188af5 100644 +--- a/test/parallel/test-http-multiple-headers.js ++++ b/test/parallel/test-http-multiple-headers.js +@@ -27,13 +27,13 @@ const server = createServer( + host, + 'transfer-encoding': 'chunked' + }); +- assert.deepStrictEqual(req.headersDistinct, { ++ assert.deepStrictEqual(req.headersDistinct, Object.assign({ __proto__: null }, { + 'connection': ['close'], + 'x-req-a': ['eee', 'fff', 'ggg', 'hhh'], + 'x-req-b': ['iii; jjj; kkk; lll'], + 'host': [host], +- 'transfer-encoding': ['chunked'] +- }); ++ 'transfer-encoding': ['chunked'], ++ })); + + req.on('end', function() { + assert.deepStrictEqual(req.rawTrailers, [ +@@ -46,7 +46,7 @@ const server = createServer( + ); + assert.deepStrictEqual( + req.trailersDistinct, +- { 'x-req-x': ['xxx', 'yyy'], 'x-req-y': ['zzz; www'] } ++ Object.assign({ __proto__: null }, { 'x-req-x': ['xxx', 'yyy'], 'x-req-y': ['zzz; www'] }) + ); + + res.setHeader('X-Res-a', 'AAA'); +@@ -129,14 +129,14 @@ server.listen(0, common.mustCall(() => { + 'x-res-d': 'JJJ; KKK; LLL', + 'transfer-encoding': 'chunked' + }); +- assert.deepStrictEqual(res.headersDistinct, { ++ assert.deepStrictEqual(res.headersDistinct, Object.assign({ __proto__: null }, { + 'x-res-a': [ 'AAA', 'BBB', 'CCC' ], + 'x-res-b': [ 'DDD; EEE; FFF; GGG' ], + 'connection': [ 'close' ], + 'x-res-c': [ 'HHH', 'III' ], + 'x-res-d': [ 'JJJ; KKK; LLL' ], +- 'transfer-encoding': [ 'chunked' ] +- }); ++ 'transfer-encoding': [ 'chunked' ], ++ })); + + res.on('end', function() { + assert.deepStrictEqual(res.rawTrailers, [ +@@ -150,7 +150,7 @@ server.listen(0, common.mustCall(() => { + ); + assert.deepStrictEqual( + res.trailersDistinct, +- { 'x-res-x': ['XXX', 'YYY'], 'x-res-y': ['ZZZ; WWW'] } ++ Object.assign({ __proto__: null }, { 'x-res-x': ['XXX', 'YYY'], 'x-res-y': ['ZZZ; WWW'] }) + ); + server.close(); + }); diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21713.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21713.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21713.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21713.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,31 @@ +From: Filip Skokan +Date: Fri, 20 Feb 2026 12:32:14 +0100 +Subject: crypto: use timing-safe comparison in Web Cryptography HMAC + +Use `CRYPTO_memcmp` instead of `memcmp` in `HMAC` +Web Cryptography algorithm implementations. + +Ref: https://hackerone.com/reports/3533945 +PR-URL: https://github.com/nodejs-private/node-private/pull/831 +Refs: https://hackerone.com/reports/3533945 +Reviewed-By: Marco Ippolito +CVE-ID: CVE-2026-21713 +origin: https://github.com/nodejs/node/commit/cfb51fa9ce1da2a8c810ec35bcc7c000f8c94fafy +--- + src/crypto/crypto_hmac.cc | 3 ++- + 1 file changed, 2 insertions(+), 1 deletion(-) + +diff --git a/src/crypto/crypto_hmac.cc b/src/crypto/crypto_hmac.cc +index 8173946..7f4c50c 100644 +--- a/src/crypto/crypto_hmac.cc ++++ b/src/crypto/crypto_hmac.cc +@@ -270,7 +270,8 @@ Maybe HmacTraits::EncodeOutput( + *result = Boolean::New( + env->isolate(), + out->size() > 0 && out->size() == params.signature.size() && +- memcmp(out->data(), params.signature.data(), out->size()) == 0); ++ CRYPTO_memcmp( ++ out->data(), params.signature.data(), out->size()) == 0); + break; + default: + UNREACHABLE(); diff -Nru nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21714.patch nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21714.patch --- nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21714.patch 1970-01-01 00:00:00.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/CVE-2026-21714.patch 2026-04-06 14:18:52.000000000 +0000 @@ -0,0 +1,123 @@ +From: RafaelGSS +Date: Wed, 11 Mar 2026 11:22:23 -0300 +Subject: src: handle NGHTTP2_ERR_FLOW_CONTROL error code + +Refs: https://hackerone.com/reports/3531737 +PR-URL: https://github.com/nodejs-private/node-private/pull/832 +CVE-ID: CVE-2026-21714 +origin: https://github.com/nodejs/node/commit/a0c73425da4c95fbcf6c13b7fe8921301290b8e6 +--- + src/node_http2.cc | 6 ++ + test/parallel/test-http2-window-update-overflow.js | 84 ++++++++++++++++++++++ + 2 files changed, 90 insertions(+) + create mode 100644 test/parallel/test-http2-window-update-overflow.js + +diff --git a/src/node_http2.cc b/src/node_http2.cc +index 38d47f0..c2de4a3 100644 +--- a/src/node_http2.cc ++++ b/src/node_http2.cc +@@ -1020,8 +1020,14 @@ int Http2Session::OnInvalidFrame(nghttp2_session* handle, + // The GOAWAY frame includes an error code that indicates the type of error" + // The GOAWAY frame is already sent by nghttp2. We emit the error + // to liberate the Http2Session to destroy. ++ // ++ // ERR_FLOW_CONTROL: A WINDOW_UPDATE on stream 0 pushed the connection-level ++ // flow control window past 2^31-1. nghttp2 sends GOAWAY internally but ++ // without propagating this error the Http2Session would never be destroyed, ++ // causing a memory leak. + if (nghttp2_is_fatal(lib_error_code) || + lib_error_code == NGHTTP2_ERR_STREAM_CLOSED || ++ lib_error_code == NGHTTP2_ERR_FLOW_CONTROL || + lib_error_code == NGHTTP2_ERR_PROTO) { + Environment* env = session->env(); + Isolate* isolate = env->isolate(); +diff --git a/test/parallel/test-http2-window-update-overflow.js b/test/parallel/test-http2-window-update-overflow.js +new file mode 100644 +index 0000000..41488af +--- /dev/null ++++ b/test/parallel/test-http2-window-update-overflow.js +@@ -0,0 +1,84 @@ ++'use strict'; ++ ++const common = require('../common'); ++ ++if (!common.hasCrypto) ++ common.skip('missing crypto'); ++ ++const http2 = require('http2'); ++const net = require('net'); ++ ++// Regression test: a connection-level WINDOW_UPDATE that causes the flow ++// control window to exceed 2^31-1 must destroy the Http2Session (not leak it). ++// ++// nghttp2 responds with GOAWAY(FLOW_CONTROL_ERROR) internally but previously ++// Node's OnInvalidFrame callback only propagated errors for ++// NGHTTP2_ERR_STREAM_CLOSED and NGHTTP2_ERR_PROTO. The missing ++// NGHTTP2_ERR_FLOW_CONTROL case left the session unreachable after the GOAWAY, ++// causing a memory leak. ++ ++const server = http2.createServer(); ++ ++server.on('session', common.mustCall((session) => { ++ session.on('error', common.mustCall()); ++ session.on('close', common.mustCall(() => server.close())); ++})); ++ ++server.listen(0, common.mustCall(() => { ++ const conn = net.connect({ ++ port: server.address().port, ++ allowHalfOpen: true, ++ }); ++ ++ // HTTP/2 client connection preface. ++ conn.write('PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n'); ++ ++ // Empty SETTINGS frame (9-byte header, 0-byte payload). ++ const settingsFrame = Buffer.alloc(9); ++ settingsFrame[3] = 0x04; // type: SETTINGS ++ conn.write(settingsFrame); ++ ++ let inbuf = Buffer.alloc(0); ++ let state = 'settingsHeader'; ++ let settingsFrameLength; ++ ++ conn.on('data', (chunk) => { ++ inbuf = Buffer.concat([inbuf, chunk]); ++ ++ switch (state) { ++ case 'settingsHeader': ++ if (inbuf.length < 9) return; ++ settingsFrameLength = inbuf.readUIntBE(0, 3); ++ inbuf = inbuf.slice(9); ++ state = 'readingSettings'; ++ // Fallthrough ++ case 'readingSettings': { ++ if (inbuf.length < settingsFrameLength) return; ++ inbuf = inbuf.slice(settingsFrameLength); ++ state = 'done'; ++ ++ // ACK the server SETTINGS. ++ const ack = Buffer.alloc(9); ++ ack[3] = 0x04; // type: SETTINGS ++ ack[4] = 0x01; // flag: ACK ++ conn.write(ack); ++ ++ // WINDOW_UPDATE on stream 0 (connection level) with increment 2^31-1. ++ // Default connection window is 65535, so the new total would be ++ // 65535 + 2147483647 = 2147549182 > 2^31-1, triggering ++ // NGHTTP2_ERR_FLOW_CONTROL inside nghttp2. ++ const windowUpdate = Buffer.alloc(13); ++ windowUpdate.writeUIntBE(4, 0, 3); // length = 4 ++ windowUpdate[3] = 0x08; // type: WINDOW_UPDATE ++ windowUpdate[4] = 0x00; // flags: none ++ windowUpdate.writeUIntBE(0, 5, 4); // stream id: 0 ++ windowUpdate.writeUIntBE(0x7FFFFFFF, 9, 4); // increment: 2^31-1 ++ conn.write(windowUpdate); ++ } ++ } ++ }); ++ ++ // The server must close the connection after sending GOAWAY. ++ conn.on('end', common.mustCall(() => conn.end())); ++ conn.on('close', common.mustCall()); ++})); diff -Nru nodejs-18.20.4+dfsg/debian/patches/series nodejs-18.20.4+dfsg/debian/patches/series --- nodejs-18.20.4+dfsg/debian/patches/series 2024-07-09 15:36:33.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/patches/series 2026-04-06 14:18:52.000000000 +0000 @@ -28,9 +28,15 @@ mips/flaky_tests.patch build/openssl_3014.patch libuv/0000-bookworm-sync.patch -#libuv/sparc-skip-tcp_oob.diff -#iovec_rw_fix.patch -#libuv/disable_ipv6_test.patch libuv/path_max_zero_st_size -#libuv/skip-multicast-test libuv/fix-cve-2024-24806 +CVE-2025-55131.patch +CVE-2025-59465.patch +CVE-2025-59466.patch +CVE-2025-23085.patch +CVE-2025-23166.patch +CVE-2026-21637.patch +CVE-2026-21637_post1.patch +CVE-2026-21710.patch +CVE-2026-21713.patch +CVE-2026-21714.patch diff -Nru nodejs-18.20.4+dfsg/debian/salsa-ci.yml nodejs-18.20.4+dfsg/debian/salsa-ci.yml --- nodejs-18.20.4+dfsg/debian/salsa-ci.yml 2023-12-20 16:50:59.000000000 +0000 +++ nodejs-18.20.4+dfsg/debian/salsa-ci.yml 2026-04-06 14:18:52.000000000 +0000 @@ -3,6 +3,10 @@ include: - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml +build: + extends: .build-package + timeout: 5h + variables: # Save some space to avoid artifacts being too big to upload # Do not check during build: ci runs autopkgtests just after