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