diff --git a/doc/api/quic.md b/doc/api/quic.md index 36e4920860b8..67ce8555def3 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 f15051da498f..36867390b0f3 100644 --- a/src/quic/data.cc +++ b/src/quic/data.cc @@ -7,9 +7,11 @@ #include #include #include +#include #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 9c92a30c1ddf..79d7f178a86b 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 a1ab4c7090e3..00cebc5c98cd 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(nwrite)); return packet; diff --git a/src/quic/session.cc b/src/quic/session.cc index d83c1cbb89db..ba672a3bee48 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 000000000000..aaa791f82072 --- /dev/null +++ b/test/parallel/test-quic-session-close-reason-truncation.mjs @@ -0,0 +1,112 @@ +// 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'; +import { setTimeout } from 'node:timers/promises'; + +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 serverDone = Promise.withResolvers(); + + const serverEndpoint = await listen(mustCall(async (serverSession) => { + serverSession.onerror = mustCall((error) => { + assert.strictEqual(error.reason, expectedReason); + }); + await assert.rejects(serverSession.closed, { + code: 'ERR_QUIC_APPLICATION_ERROR', + reason: expectedReason, + }); + serverDone.resolve(); + })); + + const clientSession = await connect(serverEndpoint.address, { + reuseEndpoint: false, + }); + await clientSession.opened; + await setTimeout(100); + await clientSession.close({ + code: 1n, + type: 'application', + reason, + }); + + await serverDone.promise; + 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 serverDone = Promise.withResolvers(); + + const serverEndpoint = await listen(mustCall(async (serverSession) => { + serverSession.onerror = mustCall((error) => { + assert.strictEqual(error.reason, prefix); + }); + await assert.rejects(serverSession.closed, { + code: 'ERR_QUIC_APPLICATION_ERROR', + reason: prefix, + }); + serverDone.resolve(); + })); + + const clientSession = await connect(serverEndpoint.address, { + reuseEndpoint: false, + }); + await clientSession.opened; + await setTimeout(100); + await clientSession.close({ + code: 2n, + type: 'application', + reason, + }); + + await serverDone.promise; + 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 serverDone = Promise.withResolvers(); + + const serverEndpoint = await listen(mustCall(async (serverSession) => { + await serverSession.opened; + await setTimeout(100); + await serverSession.close({ + code: 3n, + type: 'application', + reason, + }); + serverDone.resolve(); + })); + + const clientSession = await connect(serverEndpoint.address, { + reuseEndpoint: false, + onerror: mustCall((error) => { + assert.strictEqual(error.reason, expectedReason); + }), + }); + await clientSession.opened; + await assert.rejects(clientSession.closed, { + code: 'ERR_QUIC_APPLICATION_ERROR', + reason: expectedReason, + }); + + await serverDone.promise; + await serverEndpoint.close(); +}