Commit f88a1d25e9d for nodejs

commit f88a1d25e9dc48a7f838280b236c4d74f76231a3
Author: Efe Karasakal <hi@efe.dev>
Date:   Sat Oct 17 23:42:53 2026 +0200

    quic: validate stream error codes

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

diff --git a/doc/api/quic.md b/doc/api/quic.md
index 3028d755916..323a2548737 100644
--- a/doc/api/quic.md
+++ b/doc/api/quic.md
@@ -1976,9 +1976,10 @@ changes:
 * `error` {any}
 * `options` {Object}
   * `code` {bigint|number} The application error code to include in the
-    `RESET_STREAM` and `STOP_SENDING` frames sent to the peer. Numbers are
-    coerced to `BigInt`. When omitted, the wire code is derived from `error`
-    (see below).
+    `RESET_STREAM` and `STOP_SENDING` frames sent to the peer. Must be a
+    non-negative 62-bit unsigned varint
+    (`0n <= code <= 2n ** 62n - 1n`). Numbers are coerced to `BigInt`. When
+    omitted, the wire code is derived from `error` (see below).
   * `reason` {string} An optional human-readable reason string. Accepted for
     symmetry with [`session.close()`][] and [`session.destroy()`][], but
     **not transmitted on the wire** — neither `RESET_STREAM` nor
@@ -2058,8 +2059,9 @@ not perform this derivation: they send `code` as given.
 added: v23.8.0
 -->

-* `code` {number|bigint} The application error code to send to the peer.
-  **Default:** `0n`.
+* `code` {number|bigint} The application error code to send to the peer. Must
+  be a non-negative 62-bit unsigned varint
+  (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0n`.

 Tells the peer that this end will not send any more data on this stream,
 sending a `RESET_STREAM` frame carrying `code`. The readable side is left
@@ -2078,8 +2080,9 @@ remote-initiated unidirectional stream, which has no writable side to abort.
 added: v23.8.0
 -->

-* `code` {number|bigint} The application error code to send to the peer.
-  **Default:** `0n`.
+* `code` {number|bigint} The application error code to send to the peer. Must
+  be a non-negative 62-bit unsigned varint
+  (`0n <= code <= 2n ** 62n - 1n`). **Default:** `0n`.

 Asks the peer to stop sending data on this stream, sending a `STOP_SENDING`
 frame carrying `code`. The writable side is left open, so this end can
diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js
index d163311dc18..177965510e0 100644
--- a/lib/internal/quic/quic.js
+++ b/lib/internal/quic/quic.js
@@ -1033,6 +1033,23 @@ function assertPrivateSymbol(privateSymbol) {
 // maximum representable code is 2**62 - 1.
 const kMaxQuicErrorCode = (1n << 62n) - 1n;

+function validateQuicErrorCode(code, name) {
+  if (typeof code !== 'bigint' && typeof code !== 'number') {
+    throw new ERR_INVALID_ARG_TYPE(name, ['bigint', 'number'], code);
+  }
+  if (typeof code === 'number' && !NumberIsInteger(code)) {
+    throw new ERR_OUT_OF_RANGE(name, 'an integer', code);
+  }
+  const numericCode = BigInt(code);
+  if (numericCode < 0n || numericCode > kMaxQuicErrorCode) {
+    throw new ERR_OUT_OF_RANGE(
+      name,
+      `>= 0 and <= ${kMaxQuicErrorCode}`,
+      code);
+  }
+  return numericCode;
+}
+
 /**
  * An Error subclass that carries an explicit numeric QUIC error code.
  * Use this when destroying a stream or aborting an outbound writer to
@@ -1087,18 +1104,11 @@ class QuicError extends Error {
     if (errorCode === undefined) {
       throw new ERR_MISSING_ARGS('options.errorCode');
     }
-    if (typeof errorCode !== 'bigint' && typeof errorCode !== 'number') {
-      throw new ERR_INVALID_ARG_TYPE('options.errorCode',
-                                     ['bigint', 'number'], errorCode);
-    }
+    const numericCode = validateQuicErrorCode(
+      errorCode,
+      'options.errorCode');
     validateString(code, 'options.code');
     validateOneOf(type, 'options.type', ['transport', 'application']);
-    const numericCode = BigInt(errorCode);
-    if (numericCode < 0n || numericCode > kMaxQuicErrorCode) {
-      throw new ERR_OUT_OF_RANGE('options.errorCode',
-                                 `>= 0 and <= ${kMaxQuicErrorCode}`,
-                                 errorCode);
-    }
     super(message);
     this.code = code;
     this.#errorCode = numericCode;
@@ -2081,11 +2091,9 @@ class QuicStream {
     // promise). The caller may retry with valid options.
     validateObject(options, 'options');
     const { code: optionCode, reason } = options;
-    if (optionCode !== undefined &&
-        typeof optionCode !== 'bigint' &&
-        typeof optionCode !== 'number') {
-      throw new ERR_INVALID_ARG_TYPE('options.code',
-                                     ['bigint', 'number'], optionCode);
+    let abortCode;
+    if (optionCode !== undefined) {
+      abortCode = validateQuicErrorCode(optionCode, 'options.code');
     }
     if (reason !== undefined) {
       validateString(reason, 'options.reason');
@@ -2093,10 +2101,7 @@ class QuicStream {
     inner.destroying = true;
     // Resolve the wire error code for any RESET_STREAM / STOP_SENDING
     // frames emitted below.
-    let abortCode;
-    if (optionCode !== undefined) {
-      abortCode = BigInt(optionCode);
-    } else if (error !== undefined) {
+    if (abortCode === undefined && error !== undefined) {
       abortCode = QuicError.isQuicError(error) ?
         error.errorCode :
         getQuicSessionState(inner.session).internalErrorCode;
@@ -2606,7 +2611,7 @@ class QuicStream {
   stopSending(code = 0n) {
     assertIsQuicStream(this);
     if (this.destroyed) return;
-    const abortCode = BigInt(code);
+    const abortCode = validateQuicErrorCode(code, 'code');
     this.#inner.stopSendingCode = abortCode;
     this.#handle.stopSending(abortCode);
   }
@@ -2621,7 +2626,7 @@ class QuicStream {
   resetStream(code = 0n) {
     assertIsQuicStream(this);
     if (this.destroyed) return;
-    this.#handle.resetStream(BigInt(code));
+    this.#handle.resetStream(validateQuicErrorCode(code, 'code'));
   }

   /**
@@ -5482,17 +5487,7 @@ function validateCloseOptions(options) {
   } = options;

   if (code !== undefined) {
-    if (typeof code !== 'bigint' && typeof code !== 'number') {
-      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);
-    }
+    validateQuicErrorCode(code, 'options.code');
   }
   validateOneOf(type, 'options.type', ['transport', 'application']);
   if (reason !== undefined) {
diff --git a/src/quic/streams.cc b/src/quic/streams.cc
index 1354ad54c47..8e9d3570938 100644
--- a/src/quic/streams.cc
+++ b/src/quic/streams.cc
@@ -432,12 +432,8 @@ struct Stream::Impl {
   JS_METHOD(StopSending) {
     Stream* stream;
     ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
-    error_code code = 0;
-    CHECK_IMPLIES(!args[0]->IsUndefined(), args[0]->IsBigInt());
-    if (!args[0]->IsUndefined()) {
-      bool unused = false;  // not used but still necessary.
-      code = args[0].As<BigInt>()->Uint64Value(&unused);
-    }
+    CHECK(args[0]->IsBigInt());
+    error_code code = args[0].As<BigInt>()->Uint64Value();

     stream->SendStopSending(code);
   }
@@ -449,12 +445,8 @@ struct Stream::Impl {
   JS_METHOD(ResetStream) {
     Stream* stream;
     ASSIGN_OR_RETURN_UNWRAP(&stream, args.This());
-    error_code code = 0;
-    CHECK_IMPLIES(!args[0]->IsUndefined(), args[0]->IsBigInt());
-    if (!args[0]->IsUndefined()) {
-      bool lossless = false;  // not used but still necessary.
-      code = args[0].As<BigInt>()->Uint64Value(&lossless);
-    }
+    CHECK(args[0]->IsBigInt());
+    error_code code = args[0].As<BigInt>()->Uint64Value();

     stream->DoStreamReset(code);
   }
diff --git a/test/parallel/test-quic-stream-error-code-validation.mjs b/test/parallel/test-quic-stream-error-code-validation.mjs
new file mode 100644
index 00000000000..4af0bff0c24
--- /dev/null
+++ b/test/parallel/test-quic-stream-error-code-validation.mjs
@@ -0,0 +1,53 @@
+// Flags: --experimental-quic --no-warnings
+
+import { hasQuic, skip, mustCall } from '../common/index.mjs';
+import assert from 'node:assert';
+
+if (!hasQuic) {
+  skip('QUIC is not enabled');
+}
+
+const { listen, connect } = await import('../common/quic.mjs');
+
+const serverEndpoint = await listen(mustCall(() => true));
+const clientSession = await connect(serverEndpoint.address);
+await clientSession.opened;
+
+const stream = await clientSession.createBidirectionalStream();
+
+const invalidTypes = ['1', true, null];
+const invalidCodes = [
+  -1,
+  1.5,
+  NaN,
+  Infinity,
+  2 ** 62,
+  -1n,
+  2n ** 62n,
+];
+
+for (const method of ['stopSending', 'resetStream']) {
+  for (const code of invalidTypes) {
+    assert.throws(() => stream[method](code), {
+      code: 'ERR_INVALID_ARG_TYPE',
+    });
+  }
+
+  for (const code of invalidCodes) {
+    assert.throws(() => stream[method](code), {
+      code: 'ERR_OUT_OF_RANGE',
+    });
+  }
+}
+
+for (const code of invalidCodes) {
+  assert.throws(() => stream.destroy(new Error('test'), { code }), {
+    code: 'ERR_OUT_OF_RANGE',
+  });
+}
+assert.strictEqual(stream.destroyed, false);
+
+stream.destroy();
+await stream.closed;
+await clientSession.close();
+await serverEndpoint.close();