Skip to content

Commit a4f6d75

Browse files
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: #66302 Reviewed-By: Tim Perry <pimterry@gmail.com>
1 parent 8314231 commit a4f6d75

3 files changed

Lines changed: 32 additions & 5 deletions

File tree

‎doc/api/quic.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -974,7 +974,8 @@ added: v23.8.0
974974

975975
* `options` {Object}
976976
* `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE`
977-
frame sent to the peer. **Default:** `0` (no error).
977+
frame sent to the peer. Must be a non-negative 62-bit unsigned varint
978+
(`0n <= code <= 2n ** 62n - 1n`). **Default:** `0` (no error).
978979
* `type` {string} Either `'transport'` or `'application'`. Determines the
979980
error code namespace used in the `CONNECTION_CLOSE` frame. When `'transport'`
980981
(the default), the frame type is `0x1c` and the code is interpreted as a QUIC
@@ -1056,7 +1057,8 @@ added: v23.8.0
10561057
* `error` {any}
10571058
* `options` {Object}
10581059
* `code` {bigint|number} The error code to include in the `CONNECTION_CLOSE`
1059-
frame sent to the peer. **Default:** `0`.
1060+
frame sent to the peer. Must be a non-negative 62-bit unsigned varint
1061+
(`0n <= code <= 2n ** 62n - 1n`). **Default:** `0`.
10601062
* `type` {string} Either `'transport'` or `'application'`. **Default:**
10611063
`'transport'`.
10621064
* `reason` {string} An optional human-readable reason string included in

‎lib/internal/quic/quic.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const {
1313
ErrorCaptureStackTrace,
1414
FunctionPrototypeBind,
1515
FunctionPrototypeCall,
16+
NumberIsInteger,
1617
ObjectDefineProperties,
1718
ObjectKeys,
1819
PromisePrototypeThen,
@@ -5485,6 +5486,13 @@ function validateCloseOptions(options) {
54855486
throw new ERR_INVALID_ARG_TYPE('options.code',
54865487
['bigint', 'number'], code);
54875488
}
5489+
if (typeof code === 'number' && !NumberIsInteger(code)) {
5490+
throw new ERR_OUT_OF_RANGE('options.code', 'an integer', code);
5491+
}
5492+
if (code < 0 || code > kMaxQuicErrorCode) {
5493+
throw new ERR_OUT_OF_RANGE('options.code',
5494+
`>= 0 and <= ${kMaxQuicErrorCode}`, code);
5495+
}
54885496
}
54895497
validateOneOf(type, 'options.type', ['transport', 'application']);
54905498
if (reason !== undefined) {

‎test/parallel/test-quic-session-destroy-validate-options.mjs‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,20 @@ assert.throws(() => clientSession.destroy(goodError, { reason: 42 }), {
9797
assert.strictEqual(clientSession.destroyed, false);
9898
assert.strictEqual(stream.destroyed, false);
9999

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

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

0 commit comments

Comments
 (0)