Commit cfea9922e7d for nodejs

commit cfea9922e7d656bfe29d697a6031f5d823377512
Author: Efe Karasakal <hi@efe.dev>
Date:   Fri Oct 30 21:17:16 2026 +0100

    quic: fix crypto error classification

    Signed-off-by: Efe Karasakal <hi@efe.dev>
    PR-URL: https://github.com/nodejs/node/pull/66968
    Reviewed-By: Tim Perry <pimterry@gmail.com>

diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js
index 6603bd45289..52f7fe63fa2 100644
--- a/lib/internal/quic/quic.js
+++ b/lib/internal/quic/quic.js
@@ -768,10 +768,11 @@ setCallbacks({
    * Called when the underlying session C++ handle is closed either normally
    * or with an error.
    * @param {number} errorType
-   * @param {number} code
+   * @param {bigint} code
    * @param {string} [reason]
-   * @param {string} [errorName] Decoded TLS alert name when `code` is a
-   *   CRYPTO_ERROR; otherwise undefined.
+   * @param {string} [errorName] Symbolic name for a recognized transport
+   *   error code, including a decoded TLS alert for CRYPTO_ERROR; otherwise
+   *   undefined.
    */
   onSessionClose(errorType, code, reason, errorName) {
     debug('session close callback', errorType, code, reason, errorName);
@@ -3915,8 +3916,9 @@ class QuicSession {

   /**
    * @param {number} errorType
-   * @param {number} code
+   * @param {bigint} code
    * @param {string} [reason]
+   * @param {string} [errorName]
    */
   [kFinishClose](errorType, code, reason, errorName) {
     // If code is zero, then we closed without an error. Yay! We can destroy
diff --git a/src/quic/data.cc b/src/quic/data.cc
index 36867390b0f..1a1d75b38a6 100644
--- a/src/quic/data.cc
+++ b/src/quic/data.cc
@@ -374,15 +374,20 @@ error_code QuicError::h3_liberr_to_code(int liberr) {
 }

 bool QuicError::is_crypto_error() const {
-  return code() & NGTCP2_CRYPTO_ERROR;
+  const auto error_code = code();
+  return type() == Type::TRANSPORT && error_code >= NGTCP2_CRYPTO_ERROR &&
+         error_code < NGTCP2_CRYPTO_ERROR + 0x100U;
 }

 std::optional<int> QuicError::get_crypto_error() const {
   if (!is_crypto_error()) return std::nullopt;
-  return code() & ~NGTCP2_CRYPTO_ERROR;
+  return static_cast<int>(code() - NGTCP2_CRYPTO_ERROR);
 }

 const char* QuicError::name() const {
+  // Application error codes are
+  // opaque to QUIC, so we only decode for transport.
+  if (type() != Type::TRANSPORT) return nullptr;
   // CRYPTO_ERROR carries a TLS alert in its low byte (RFC 9001 sec. 4.8).
   // OpenSSL's SSL_alert_desc_string_long owns a stable string for every
   // alert it knows about; we filter out the "unknown" placeholder so the
@@ -392,10 +397,8 @@ const char* QuicError::name() const {
     if (n != nullptr && std::string_view(n) != "unknown") return n;
     return nullptr;
   }
-  // Named transport-layer error codes from RFC 9000 sec. 20.1 (and the
-  // RFC 9368 version-negotiation extension). Application error codes are
-  // opaque to QUIC, so we only decode for transport.
-  if (type() != Type::TRANSPORT) return nullptr;
+  // Named transport-layer error codes from RFC 9000 sec. 20.1 and the
+  // RFC 9368 version-negotiation extension.
   switch (code()) {
     case NGTCP2_NO_ERROR:
       return "NO_ERROR";
diff --git a/src/quic/data.h b/src/quic/data.h
index 79d7f178a86..740d962638f 100644
--- a/src/quic/data.h
+++ b/src/quic/data.h
@@ -267,7 +267,8 @@ class QuicError final : public MemoryRetainer {
   bool is_crypto_error() const;
   std::optional<int> get_crypto_error() const;

-  // Returns a human-readable name for this error if known, or nullptr
+  // Returns a human-readable name for a recognized transport error, including
+  // a decoded TLS alert for CRYPTO_ERROR, or nullptr if the name is unknown.
   const char* name() const;

   // Note that since application errors are application-specific and we
@@ -284,11 +285,12 @@ class QuicError final : public MemoryRetainer {

   std::string ToString() const;

-  // Returns an array containing [type, code, reason], where type is a string
-  // representation of the error type, code is a bigint representation of the
-  // error code, and reason is a string representation of the error reason, or
-  // undefined if the reason is the empty string. This is expected to be used
-  // to construct a JS error object from the information in JS.
+  // Returns an array containing [type, code, reason, error_name], where type
+  // is a string representation of the error type, code is a bigint
+  // representation of the error code, and reason is a string representation
+  // of the error reason, or undefined if the reason is empty. error_name is
+  // the symbolic name of a recognized transport error, or undefined. This is
+  // expected to be used to construct a JS error object.
   v8::MaybeLocal<v8::Value> ToV8Value(Environment* env) const;
   inline v8::MaybeLocal<v8::Value> ToV8Value(Realm* realm) const {
     return ToV8Value(realm->env());
diff --git a/test/parallel/test-quic-h3-error-name.mjs b/test/parallel/test-quic-h3-error-name.mjs
new file mode 100644
index 00000000000..811bc933ab9
--- /dev/null
+++ b/test/parallel/test-quic-h3-error-name.mjs
@@ -0,0 +1,124 @@
+// Flags: --experimental-quic --experimental-stream-iter --no-warnings
+
+// Verify that error names are decoded only within the QUIC transport error
+// namespace and the CRYPTO_ERROR range.
+
+import { hasQuic, skip, mustCall, mustNotCall } from '../common/index.mjs';
+import assert from 'node:assert';
+import * as fixtures from '../common/fixtures.mjs';
+
+if (!hasQuic) {
+  skip('QUIC is not enabled');
+}
+
+const { listen, connect } = await import('node:quic');
+const { createPrivateKey } = await import('node:crypto');
+const { bytes } = await import('stream/iter');
+
+const key = createPrivateKey(fixtures.readKey('agent1-key.pem'));
+const cert = fixtures.readKey('agent1-cert.pem');
+const decoder = new TextDecoder();
+
+async function receiveCloseError(closeOptions) {
+  const serverDone = Promise.withResolvers();
+  const serverEndpoint = await listen(mustCall((serverSession) => {
+    serverSession.onstream = mustCall(async (stream) => {
+      await stream.closed;
+      serverSession.close(closeOptions);
+      serverDone.resolve();
+    });
+  }), {
+    alpn: ['h3'],
+    sni: { '*': { keys: [key], certs: [cert] } },
+    onheaders: mustCall(function() {
+      this.sendHeaders({ ':status': '200' });
+      this.writer.writeSync('ok');
+      this.writer.endSync();
+    }),
+  });
+
+  const clientSession = await connect(serverEndpoint.address, {
+    alpn: 'h3',
+    servername: 'localhost',
+    verifyPeer: 'manual',
+  });
+  const closed = clientSession.closed.then(
+    mustNotCall('session close must not resolve'),
+    mustCall((error) => error),
+  );
+  await clientSession.opened;
+
+  const stream = await clientSession.createBidirectionalStream({
+    headers: {
+      ':method': 'GET',
+      ':path': '/',
+      ':scheme': 'https',
+      ':authority': 'localhost',
+    },
+    onheaders: mustCall(function(headers) {
+      assert.strictEqual(headers[':status'], 200);
+    }),
+  });
+
+  const body = await bytes(stream);
+  assert.strictEqual(decoder.decode(body), 'ok');
+  await Promise.all([stream.closed, serverDone.promise]);
+
+  const error = await closed;
+  await serverEndpoint.close();
+  return error;
+}
+
+{
+  const error = await receiveCloseError({
+    code: 0x10an,
+    type: 'application',
+  });
+  assert.strictEqual(error.code, 'ERR_QUIC_APPLICATION_ERROR');
+  assert.strictEqual(error.type, 'application');
+  assert.strictEqual(error.errorCode, 0x10an);
+  assert.strictEqual(error.errorName, undefined);
+  assert.strictEqual(error.message, 'QUIC application error 266');
+}
+
+{
+  const error = await receiveCloseError({
+    code: 0x0fn,
+    type: 'transport',
+  });
+  assert.strictEqual(error.code, 'ERR_QUIC_TRANSPORT_ERROR');
+  assert.strictEqual(error.type, 'transport');
+  assert.strictEqual(error.errorCode, 0x0fn);
+  assert.strictEqual(error.errorName, 'AEAD_LIMIT_REACHED');
+  assert.strictEqual(
+    error.message,
+    'QUIC transport error AEAD_LIMIT_REACHED (15)',
+  );
+}
+
+{
+  const error = await receiveCloseError({
+    code: 0x300n,
+    type: 'transport',
+  });
+  assert.strictEqual(error.code, 'ERR_QUIC_TRANSPORT_ERROR');
+  assert.strictEqual(error.type, 'transport');
+  assert.strictEqual(error.errorCode, 0x300n);
+  assert.strictEqual(error.errorName, undefined);
+  assert.strictEqual(error.message, 'QUIC transport error 768');
+}
+
+{
+  const error = await receiveCloseError({
+    code: 0x10an,
+    type: 'transport',
+  });
+  assert.strictEqual(error.code, 'ERR_QUIC_TRANSPORT_ERROR');
+  assert.strictEqual(error.type, 'transport');
+  assert.strictEqual(error.errorCode, 0x10an);
+  assert.strictEqual(error.errorName, 'unexpected message');
+  assert.strictEqual(
+    error.message,
+    'QUIC transport error unexpected message (266)',
+  );
+}