Commit a4f6d7587b9 for nodejs

commit a4f6d7587b9e3017826410652d828b4be4967ba9
Author: Christian Aurich Zanettini Martins <christian.aurichzm@gmail.com>
Date:   Tue Oct 6 14:40:30 2026 -0300

    quic: validate session close error codes

    `session.close()` and `session.destroy()` only checked that
    `options.code` was a bigint or a number. Numbers were then converted
    to `uint64_t` unchecked, so fractions were truncated and negative,
    non-finite or too large values were undefined behavior. Bigints were
    only checked to fit in 64 bits, but QUIC error codes are
    variable-length integers limited to 62 bits, so larger codes cannot
    be encoded in the `CONNECTION_CLOSE` frame.

    Reject codes that are not integers between `0` and `2n ** 62n - 1n`,
    the range `QuicError` already enforces, before any close or destroy
    side effects.

    Signed-off-by: Christian Aurich Zanettini Martins <christian.aurichzm@gmail.com>
    PR-URL: https://github.com/nodejs/node/pull/66302
    Reviewed-By: Tim Perry <pimterry@gmail.com>

diff --git a/doc/api/quic.md b/doc/api/quic.md
index 5bdac8189fa..05de774dbd9 100644
--- a/doc/api/quic.md
+++ b/doc/api/quic.md
@@ -974,7 +974,8 @@ added: v23.8.0

 * `options` {Object}
   * `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE`
-    frame sent to the peer. **Default:** `0` (no error).
+    frame sent to the peer. Must be a non-negative 62-bit unsigned varint
+    (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0` (no error).
   * `type` {string} Either `'transport'` or `'application'`. Determines the
     error code namespace used in the `CONNECTION_CLOSE` frame. When `'transport'`
     (the default), the frame type is `0x1c` and the code is interpreted as a QUIC
@@ -1056,7 +1057,8 @@ added: v23.8.0
 * `error` {any}
 * `options` {Object}
   * `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE`
-    frame sent to the peer. **Default:** `0`.
+    frame sent to the peer. Must be a non-negative 62-bit unsigned varint
+    (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0`.
   * `type` {string} Either `'transport'` or `'application'`. **Default:**
     `'transport'`.
   * `reason` {string} An optional human-readable reason string included in
diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js
index 7809a32665b..633e1f4e4de 100644
--- a/lib/internal/quic/quic.js
+++ b/lib/internal/quic/quic.js
@@ -13,6 +13,7 @@ const {
   ErrorCaptureStackTrace,
   FunctionPrototypeBind,
   FunctionPrototypeCall,
+  NumberIsInteger,
   ObjectDefineProperties,
   ObjectKeys,
   PromisePrototypeThen,
@@ -5485,6 +5486,13 @@ function validateCloseOptions(options) {
       throw new ERR_INVALID_ARG_TYPE('options.code',
                                      ['bigint', 'number'], code);
     }
+    if (typeof code === 'number' && !NumberIsInteger(code)) {
+      throw new ERR_OUT_OF_RANGE('options.code', 'an integer', code);
+    }
+    if (code < 0 || code > kMaxQuicErrorCode) {
+      throw new ERR_OUT_OF_RANGE('options.code',
+                                 `>= 0 and <= ${kMaxQuicErrorCode}`, code);
+    }
   }
   validateOneOf(type, 'options.type', ['transport', 'application']);
   if (reason !== undefined) {
diff --git a/test/parallel/test-quic-session-destroy-validate-options.mjs b/test/parallel/test-quic-session-destroy-validate-options.mjs
index b188033f573..a57b718f812 100644
--- a/test/parallel/test-quic-session-destroy-validate-options.mjs
+++ b/test/parallel/test-quic-session-destroy-validate-options.mjs
@@ -97,6 +97,20 @@ assert.throws(() => clientSession.destroy(goodError, { reason: 42 }), {
 assert.strictEqual(clientSession.destroyed, false);
 assert.strictEqual(stream.destroyed, false);

+// 5. options.code does not fit in a QUIC varint -> throws ERR_OUT_OF_RANGE,
+//    from both destroy() and close().
+for (const code of [-1, 1.5, NaN, Infinity, 2 ** 62, -1n, 2n ** 62n]) {
+  assert.throws(() => clientSession.destroy(goodError, { code }), {
+    code: 'ERR_OUT_OF_RANGE',
+  });
+  assert.throws(() => clientSession.close({ code }), {
+    code: 'ERR_OUT_OF_RANGE',
+  });
+}
+assert.strictEqual(clientSession.destroyed, false);
+assert.strictEqual(clientSession.closing, false);
+assert.strictEqual(stream.destroyed, false);
+
 // Now switch the handlers to expect the real teardown so the final
 // destroy with valid options can run cleanly.
 diagnostics_channel.unsubscribe('quic.session.error', errSub);
@@ -107,19 +121,22 @@ stream.onerror = mustCall((err) => { assert.strictEqual(err, goodError); });
 // final destroy, so the rejections do not race ahead of any awaits in
 // the test body. The client rejects with the original `goodError`;
 // the server decodes the CONNECTION_CLOSE frame transport code into
-// an `ERR_QUIC_TRANSPORT_ERROR`.
+// an `ERR_QUIC_TRANSPORT_ERROR`. The code is the largest one that fits
+// in a QUIC varint, so it must be accepted and arrive unchanged.
+const maxCode = 2n ** 62n - 1n;
 const clientClosedAssertion = assert.rejects(clientSession.closed, goodError);
 const serverClosedAssertion = assert.rejects(serverSession.closed, mustCall((err) => {
   assert.strictEqual(err.code, 'ERR_QUIC_TRANSPORT_ERROR');
+  assert.strictEqual(err.errorCode, maxCode);
   return true;
 }));

-// 5. Valid options after the failed attempts -> session destroys
+// 6. Valid options after the failed attempts -> session destroys
 //    normally, the underlying handle sends CONNECTION_CLOSE with the
 //    supplied transport code, and the local closed promise rejects
 //    with the original error.
 clientSession.destroy(goodError, {
-  code: 1n,
+  code: maxCode,
   type: 'transport',
   reason: 'after validation throw',
 });