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)',
+ );
+}