Commit 1a54c4fe8cf for nodejs

commit 1a54c4fe8cf11d03004adec600949cd0e4411d31
Author: Efe Karasakal <hi@efe.dev>
Date:   Tue Oct 20 13:01:08 2026 +0200

    quic: truncate CONNECTION_CLOSE frame reason field

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

diff --git a/doc/api/quic.md b/doc/api/quic.md
index 323a2548737..6df3f62da17 100644
--- a/doc/api/quic.md
+++ b/doc/api/quic.md
@@ -987,7 +987,8 @@ added: v23.8.0
     is application-specific. **Default:** `'transport'`.
   * `reason` {string} An optional human-readable reason string included in
     the `CONNECTION_CLOSE` frame. Per RFC 9000, this is for diagnostic purposes
-    only and should not be used for machine-readable error descriptions.
+    only and should not be used for machine-readable error descriptions. If its
+    UTF-8 encoding exceeds 256 bytes, it is truncated to at most 256 bytes.
 * Returns: {Promise}

 Initiate a graceful close of the session. Existing streams will be allowed
@@ -1066,7 +1067,9 @@ added: v23.8.0
   * `type` {string} Either `'transport'` or `'application'`. **Default:**
     `'transport'`.
   * `reason` {string} An optional human-readable reason string included in
-    the `CONNECTION_CLOSE` frame.
+    the `CONNECTION_CLOSE` frame. Per RFC 9000, this is for diagnostic purposes
+    only and should not be used for machine-readable error descriptions. If its
+    UTF-8 encoding exceeds 256 bytes, it is truncated to at most 256 bytes.

 Immediately destroy the session. All streams will be destroyed and the
 session will be closed. If `error` is provided and [`session.onerror`][] is
diff --git a/src/quic/data.cc b/src/quic/data.cc
index f15051da498..36867390b0f 100644
--- a/src/quic/data.cc
+++ b/src/quic/data.cc
@@ -7,9 +7,11 @@
 #include <openssl/ssl.h>
 #include <string_bytes.h>
 #include <v8.h>
+#include <algorithm>
 #include "bindingdata.h"
 #include "data.h"
 #include "defs.h"
+#include "simdutf.h"
 #include "util.h"

 namespace node {
@@ -218,6 +220,9 @@ void Store::MemoryInfo(MemoryTracker* tracker) const {
 // ============================================================================

 namespace {
+
+constexpr size_t kMaxConnectionCloseReasonLength = 256;
+
 constexpr std::string_view TypeName(QuicError::Type type) {
   switch (type) {
     case QuicError::Type::APPLICATION:
@@ -332,6 +337,18 @@ QuicError::operator const ngtcp2_ccerr*() const {
   return ptr_;
 }

+ngtcp2_ccerr QuicError::ToNgtcp2ConnectionCloseError() const {
+  ngtcp2_ccerr error = *ptr_;
+  error.reason = reason_c_str();
+
+  const size_t reasonlen =
+      std::min(reason_.size(), kMaxConnectionCloseReasonLength);
+
+  error.reasonlen = simdutf::trim_partial_utf8(reason_.data(), reasonlen);
+
+  return error;
+}
+
 std::string QuicError::reason_for_liberr(int liberr) {
   return ngtcp2_strerror(liberr);
 }
diff --git a/src/quic/data.h b/src/quic/data.h
index 9c92a30c1dd..79d7f178a86 100644
--- a/src/quic/data.h
+++ b/src/quic/data.h
@@ -260,6 +260,8 @@ class QuicError final : public MemoryRetainer {
   operator const ngtcp2_ccerr&() const;
   operator const ngtcp2_ccerr*() const;

+  ngtcp2_ccerr ToNgtcp2ConnectionCloseError() const;
+
   // Crypto errors are a subset of transport errors. The error code includes
   // the TLS alert code embedded within it.
   bool is_crypto_error() const;
diff --git a/src/quic/packet.cc b/src/quic/packet.cc
index a1ab4c7090e..00cebc5c98c 100644
--- a/src/quic/packet.cc
+++ b/src/quic/packet.cc
@@ -110,8 +110,9 @@ Packet::Ptr Packet::CreateConnectionClosePacket(
   if (!packet) return packet;
   ngtcp2_vec vec = *packet;

+  ngtcp2_ccerr wire_error = error.ToNgtcp2ConnectionCloseError();
   ssize_t nwrite = ngtcp2_conn_write_connection_close(
-      conn, nullptr, nullptr, vec.base, vec.len, error, uv_hrtime());
+      conn, nullptr, nullptr, vec.base, vec.len, &wire_error, uv_hrtime());
   if (nwrite < 0) return Ptr();
   packet->Truncate(static_cast<size_t>(nwrite));
   return packet;
diff --git a/src/quic/session.cc b/src/quic/session.cc
index d83c1cbb89d..ba672a3bee4 100644
--- a/src/quic/session.cc
+++ b/src/quic/session.cc
@@ -3680,13 +3680,9 @@ void Session::SendConnectionClose() {

   ngtcp2_vec vec = *packet;
   Path path(impl_->local_address_, impl_->remote_address_);
-  ssize_t nwrite = ngtcp2_conn_write_connection_close(*this,
-                                                      &path,
-                                                      nullptr,
-                                                      vec.base,
-                                                      vec.len,
-                                                      impl_->last_error_,
-                                                      uv_hrtime());
+  ngtcp2_ccerr wire_error = impl_->last_error_.ToNgtcp2ConnectionCloseError();
+  ssize_t nwrite = ngtcp2_conn_write_connection_close(
+      *this, &path, nullptr, vec.base, vec.len, &wire_error, uv_hrtime());

   if (nwrite < 0) [[unlikely]] {
     return ErrorAndSilentClose();
diff --git a/test/parallel/test-quic-session-close-reason-truncation.mjs b/test/parallel/test-quic-session-close-reason-truncation.mjs
new file mode 100644
index 00000000000..a2a958ae895
--- /dev/null
+++ b/test/parallel/test-quic-session-close-reason-truncation.mjs
@@ -0,0 +1,110 @@
+// Flags: --experimental-quic --no-warnings
+
+// CONNECTION_CLOSE reason strings are truncated to 256 UTF-8 bytes.
+
+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 kMaxReasonLength = 256;
+
+// Client close with an ASCII reason longer than the wire limit.
+{
+  const reason = 'a'.repeat(kMaxReasonLength + 1);
+  const expectedReason = reason.slice(0, kMaxReasonLength);
+  const serverSessionReady = Promise.withResolvers();
+
+  const serverEndpoint = await listen(mustCall(async (serverSession) => {
+    serverSession.onerror = mustCall((error) => {
+      assert.strictEqual(error.reason, expectedReason);
+    });
+    await serverSession.opened;
+    serverSessionReady.resolve(serverSession);
+  }));
+
+  const clientSession = await connect(serverEndpoint.address, {
+    reuseEndpoint: false,
+  });
+  await clientSession.opened;
+  const serverSession = await serverSessionReady.promise;
+  await clientSession.close({
+    code: 1n,
+    type: 'application',
+    reason,
+  });
+
+  await assert.rejects(serverSession.closed, {
+    code: 'ERR_QUIC_APPLICATION_ERROR',
+    reason: expectedReason,
+  });
+  await serverEndpoint.close();
+}
+
+// Client close where truncating at the byte limit would split a UTF-8 sequence.
+{
+  const prefix = 'a'.repeat(kMaxReasonLength - 1);
+  const reason = `${prefix}€`;
+  const serverSessionReady = Promise.withResolvers();
+
+  const serverEndpoint = await listen(mustCall(async (serverSession) => {
+    serverSession.onerror = mustCall((error) => {
+      assert.strictEqual(error.reason, prefix);
+    });
+    await serverSession.opened;
+    serverSessionReady.resolve(serverSession);
+  }));
+
+  const clientSession = await connect(serverEndpoint.address, {
+    reuseEndpoint: false,
+  });
+  await clientSession.opened;
+  const serverSession = await serverSessionReady.promise;
+  await clientSession.close({
+    code: 2n,
+    type: 'application',
+    reason,
+  });
+
+  await assert.rejects(serverSession.closed, {
+    code: 'ERR_QUIC_APPLICATION_ERROR',
+    reason: prefix,
+  });
+  await serverEndpoint.close();
+}
+
+// Server close exercises the server-specific CONNECTION_CLOSE packet path.
+{
+  const reason = 's'.repeat(kMaxReasonLength + 1);
+  const expectedReason = reason.slice(0, kMaxReasonLength);
+  const serverSessionReady = Promise.withResolvers();
+
+  const serverEndpoint = await listen(mustCall(async (serverSession) => {
+    await serverSession.opened;
+    serverSessionReady.resolve(serverSession);
+  }));
+
+  const clientSession = await connect(serverEndpoint.address, {
+    reuseEndpoint: false,
+    onerror: mustCall((error) => {
+      assert.strictEqual(error.reason, expectedReason);
+    }),
+  });
+  await clientSession.opened;
+  const serverSession = await serverSessionReady.promise;
+  await serverSession.close({
+    code: 3n,
+    type: 'application',
+    reason,
+  });
+
+  await assert.rejects(clientSession.closed, {
+    code: 'ERR_QUIC_APPLICATION_ERROR',
+    reason: expectedReason,
+  });
+  await serverEndpoint.close();
+}