Skip to content

Commit e1ae3a5

Browse files
authored
quic: add proper error codes & messages for QUIC failures
Signed-off-by: Tim Perry <pimterry@gmail.com> PR-URL: #63198 Reviewed-By: Ethan Arrowood <ethan@arrowood.dev> Reviewed-By: Stephen Belanger <admin@stephenbelanger.com>
1 parent 54f7e89 commit e1ae3a5

13 files changed

Lines changed: 186 additions & 58 deletions

lib/eslint.config_partial.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const noRestrictedSyntax = [
2323
message: "`btoa` supports only latin-1 charset, use Buffer.from(str).toString('base64') instead",
2424
},
2525
{
26-
selector: 'NewExpression[callee.name=/Error$/]:not([callee.name=/^(AssertionError|NghttpError|AbortError|NodeAggregateError|QuotaExceededError)$/])',
26+
selector: 'NewExpression[callee.name=/Error$/]:not([callee.name=/^(AssertionError|NghttpError|AbortError|NodeAggregateError|QuicError|QuotaExceededError)$/])',
2727
message: "Use an error exported by 'internal/errors' instead.",
2828
},
2929
{

lib/internal/errors.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,14 +1689,12 @@ E('ERR_PERFORMANCE_INVALID_TIMESTAMP',
16891689
E('ERR_PERFORMANCE_MEASURE_INVALID_OPTIONS', '%s', TypeError);
16901690
E('ERR_PROXY_INVALID_CONFIG', '%s', Error);
16911691
E('ERR_PROXY_TUNNEL', '%s', Error);
1692-
E('ERR_QUIC_APPLICATION_ERROR', 'A QUIC application error occurred. %d [%s]', Error);
16931692
E('ERR_QUIC_CONNECTION_FAILED', 'QUIC connection failed', Error);
16941693
E('ERR_QUIC_ENDPOINT_CLOSED', 'QUIC endpoint closed: %s (%d)', Error);
16951694
E('ERR_QUIC_OPEN_STREAM_FAILED', 'Failed to open QUIC stream', Error);
16961695
E('ERR_QUIC_STREAM_ABORTED', '%s', Error);
16971696
E('ERR_QUIC_STREAM_RESET',
16981697
'The QUIC stream was reset by the peer with error code %d', Error);
1699-
E('ERR_QUIC_TRANSPORT_ERROR', 'A QUIC transport error occurred. %d [%s]', Error);
17001698
E('ERR_QUIC_VERSION_NEGOTIATION_ERROR', 'The QUIC session requires version negotiation', Error);
17011699
E('ERR_REQUIRE_ASYNC_MODULE', function(filename, parentFilename) {
17021700
let message = 'require() cannot be used on an ESM ' +

lib/internal/quic/quic.js

Lines changed: 65 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const {
99
ArrayPrototypePush,
1010
BigInt,
1111
DataViewPrototypeGetByteLength,
12+
ErrorCaptureStackTrace,
1213
FunctionPrototypeBind,
1314
Number,
1415
ObjectDefineProperties,
@@ -108,13 +109,11 @@ const {
108109
ERR_INVALID_THIS,
109110
ERR_MISSING_ARGS,
110111
ERR_OUT_OF_RANGE,
111-
ERR_QUIC_APPLICATION_ERROR,
112112
ERR_QUIC_CONNECTION_FAILED,
113113
ERR_QUIC_ENDPOINT_CLOSED,
114114
ERR_QUIC_OPEN_STREAM_FAILED,
115115
ERR_QUIC_STREAM_ABORTED,
116116
ERR_QUIC_STREAM_RESET,
117-
ERR_QUIC_TRANSPORT_ERROR,
118117
ERR_QUIC_VERSION_NEGOTIATION_ERROR,
119118
},
120119
} = require('internal/errors');
@@ -738,10 +737,12 @@ setCallbacks({
738737
* @param {number} errorType
739738
* @param {number} code
740739
* @param {string} [reason]
740+
* @param {string} [errorName] Decoded TLS alert name when `code` is a
741+
* CRYPTO_ERROR; otherwise undefined.
741742
*/
742-
onSessionClose(errorType, code, reason) {
743-
debug('session close callback', errorType, code, reason);
744-
this[kOwner][kFinishClose](errorType, code, reason);
743+
onSessionClose(errorType, code, reason, errorName) {
744+
debug('session close callback', errorType, code, reason, errorName);
745+
this[kOwner][kFinishClose](errorType, code, reason, errorName);
745746
},
746747

747748
/**
@@ -931,8 +932,12 @@ setCallbacks({
931932
// was an abnormal termination even if the session closed cleanly.
932933
const resetCode = getQuicStreamState(this[kOwner]).resetCode;
933934
if (resetCode !== undefined && resetCode > 0n) {
934-
error = new ERR_QUIC_APPLICATION_ERROR(
935-
resetCode, `stream reset with code ${resetCode}`);
935+
error = makeQuicError(
936+
'ERR_QUIC_APPLICATION_ERROR',
937+
'QUIC application error',
938+
'application',
939+
resetCode,
940+
`stream reset with code ${resetCode}`);
936941
}
937942
}
938943
debug(`stream ${this[kOwner].id} closed callback with error: ${error}`);
@@ -1054,21 +1059,50 @@ class QuicError extends Error {
10541059
}
10551060
}
10561061

1057-
// Converts a raw QuicError array [type, code, reason] from C++ into a
1058-
// proper Node.js Error object.
1062+
// Build the human-readable message for an ERR_QUIC_TRANSPORT_ERROR or
1063+
// ERR_QUIC_APPLICATION_ERROR. `errorName` is the symbolic name for
1064+
// the wire code when known: either the OpenSSL-decoded TLS alert
1065+
// (CRYPTO_ERROR; 0x100..0x1ff) or one of the named transport codes
1066+
// from RFC 9000 (e.g. PROTOCOL_VIOLATION). Otherwise undefined.
1067+
// `reason` is the peer-supplied UTF-8 reason string from the
1068+
// CONNECTION_CLOSE / RESET_STREAM frame, often empty.
1069+
function quicErrorMessage(prefix, errorCode, reason, errorName) {
1070+
let msg = `${prefix} `;
1071+
msg += errorName ? `${errorName} (${errorCode})` : `${errorCode}`;
1072+
if (reason) msg += `: ${reason}`;
1073+
return msg;
1074+
}
1075+
1076+
function makeQuicError(code, prefix, type, errorCode, reason, errorName) {
1077+
const err = new QuicError(
1078+
quicErrorMessage(prefix, errorCode, reason, errorName),
1079+
{ errorCode, code, type });
1080+
ErrorCaptureStackTrace(err, makeQuicError);
1081+
if (reason) err.reason = reason;
1082+
if (errorName) err.errorName = errorName;
1083+
return err;
1084+
}
1085+
10591086
function convertQuicError(error) {
10601087
const type = error[0];
10611088
const code = error[1];
10621089
const reason = error[2];
1090+
const errorName = error[3];
10631091
switch (type) {
10641092
case 'transport':
1065-
return new ERR_QUIC_TRANSPORT_ERROR(code, reason);
1093+
return makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
1094+
'QUIC transport error',
1095+
'transport', code, reason, errorName);
10661096
case 'application':
1067-
return new ERR_QUIC_APPLICATION_ERROR(code, reason);
1097+
return makeQuicError('ERR_QUIC_APPLICATION_ERROR',
1098+
'QUIC application error',
1099+
'application', code, reason, errorName);
10681100
case 'version_negotiation':
10691101
return new ERR_QUIC_VERSION_NEGOTIATION_ERROR();
10701102
default:
1071-
return new ERR_QUIC_TRANSPORT_ERROR(code, reason);
1103+
return makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
1104+
'QUIC transport error',
1105+
'transport', code, reason, errorName);
10721106
}
10731107
}
10741108

@@ -3575,7 +3609,7 @@ class QuicSession {
35753609
* @param {number} code
35763610
* @param {string} [reason]
35773611
*/
3578-
[kFinishClose](errorType, code, reason) {
3612+
[kFinishClose](errorType, code, reason, errorName) {
35793613
// If code is zero, then we closed without an error. Yay! We can destroy
35803614
// safely without specifying an error.
35813615
if (code === 0n) {
@@ -3584,7 +3618,8 @@ class QuicSession {
35843618
return;
35853619
}
35863620

3587-
debug('finishing closing the session with an error', errorType, code, reason);
3621+
debug('finishing closing the session with an error',
3622+
errorType, code, reason, errorName);
35883623

35893624
// If the local side initiated this close with an error code (via
35903625
// close({ code })), this is an intentional shutdown; not an error.
@@ -3611,10 +3646,14 @@ class QuicSession {
36113646
// session would leak with `closed` hanging forever.
36123647
switch (errorType) {
36133648
case 0: /* Transport Error */
3614-
this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason));
3649+
this.destroy(makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
3650+
'QUIC transport error',
3651+
'transport', code, reason, errorName));
36153652
break;
36163653
case 1: /* Application Error */
3617-
this.destroy(new ERR_QUIC_APPLICATION_ERROR(code, reason));
3654+
this.destroy(makeQuicError('ERR_QUIC_APPLICATION_ERROR',
3655+
'QUIC application error',
3656+
'application', code, reason, errorName));
36183657
break;
36193658
case 2: /* Version Negotiation Error */
36203659
this.destroy(new ERR_QUIC_VERSION_NEGOTIATION_ERROR());
@@ -3623,7 +3662,9 @@ class QuicSession {
36233662
this.destroy();
36243663
break;
36253664
default:
3626-
this.destroy(new ERR_QUIC_TRANSPORT_ERROR(code, reason));
3665+
this.destroy(makeQuicError('ERR_QUIC_TRANSPORT_ERROR',
3666+
'QUIC transport error',
3667+
'transport', code, reason, errorName));
36273668
break;
36283669
}
36293670
}
@@ -3874,9 +3915,13 @@ class QuicSession {
38743915
// decide. In 'strict' mode, the handshake already failed at the C++
38753916
// level (SSL_VERIFY_PEER) so we won't reach here.
38763917
if (inner.verifyPeer === 'auto' && validationErrorReason !== undefined) {
3877-
const err = new ERR_QUIC_TRANSPORT_ERROR(
3878-
0, `Peer certificate validation failed: ${validationErrorReason}` +
3879-
` [${validationErrorCode}]`);
3918+
const err = makeQuicError(
3919+
'ERR_QUIC_TRANSPORT_ERROR',
3920+
'QUIC transport error',
3921+
'transport',
3922+
0n,
3923+
`Peer certificate validation failed: ${validationErrorReason}` +
3924+
` [${validationErrorCode}]`);
38803925
inner.pendingOpen.reject?.(err);
38813926
inner.pendingOpen.resolve = undefined;
38823927
inner.pendingOpen.reject = undefined;

src/quic/bindingdata.cc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,14 @@ QUIC_JS_CALLBACKS(V)
435435

436436
#undef V
437437

438+
Local<String> BindingData::error_name_string(const char* name) {
439+
auto& slot = error_name_strings_[name];
440+
if (slot.IsEmpty()) {
441+
slot.Set(env()->isolate(), OneByteString(env()->isolate(), name));
442+
}
443+
return slot.Get(env()->isolate());
444+
}
445+
438446
JS_METHOD_IMPL(BindingData::SetCallbacks) {
439447
auto env = Environment::GetCurrent(args);
440448
auto isolate = env->isolate();

src/quic/bindingdata.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,8 @@ class BindingData final
305305

306306
std::unordered_map<Endpoint*, BaseObjectPtr<BaseObject>> listening_endpoints;
307307

308+
v8::Local<v8::String> error_name_string(const char* name);
309+
308310
size_t current_ngtcp2_memory_ = 0;
309311

310312
// The following set up various storage and accessors for common strings,
@@ -357,6 +359,9 @@ class BindingData final
357359
QUIC_JS_CALLBACKS(V)
358360
#undef V
359361

362+
// Lazy cache backing error_name_string()
363+
std::unordered_map<const char*, v8::Eternal<v8::String>> error_name_strings_;
364+
360365
std::unique_ptr<SessionManager> session_manager_;
361366

362367
// Type-erased arena storage. The concrete AliasedStructArena<T> types

src/quic/data.cc

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
#if HAVE_OPENSSL && HAVE_QUIC
22
#include "guard.h"
33
#ifndef OPENSSL_NO_QUIC
4-
#include "data.h"
54
#include <env-inl.h>
65
#include <memory_tracker-inl.h>
76
#include <ngtcp2/ngtcp2.h>
87
#include <node_sockaddr-inl.h>
8+
#include <openssl/ssl.h>
99
#include <string_bytes.h>
1010
#include <v8.h>
11+
#include "bindingdata.h"
12+
#include "data.h"
1113
#include "defs.h"
1214
#include "util.h"
1315

@@ -363,6 +365,62 @@ std::optional<int> QuicError::get_crypto_error() const {
363365
return code() & ~NGTCP2_CRYPTO_ERROR;
364366
}
365367

368+
const char* QuicError::name() const {
369+
// CRYPTO_ERROR carries a TLS alert in its low byte (RFC 9001 sec. 4.8).
370+
// OpenSSL's SSL_alert_desc_string_long owns a stable string for every
371+
// alert it knows about; we filter out the "unknown" placeholder so the
372+
// JS side can present `errorName` as undefined for unrecognised alerts.
373+
if (auto alert = get_crypto_error()) {
374+
const char* n = SSL_alert_desc_string_long(*alert);
375+
if (n != nullptr && std::string_view(n) != "unknown") return n;
376+
return nullptr;
377+
}
378+
// Named transport-layer error codes from RFC 9000 sec. 20.1 (and the
379+
// RFC 9368 version-negotiation extension). Application error codes are
380+
// opaque to QUIC, so we only decode for transport.
381+
if (type() != Type::TRANSPORT) return nullptr;
382+
switch (code()) {
383+
case NGTCP2_NO_ERROR:
384+
return "NO_ERROR";
385+
case NGTCP2_INTERNAL_ERROR:
386+
return "INTERNAL_ERROR";
387+
case NGTCP2_CONNECTION_REFUSED:
388+
return "CONNECTION_REFUSED";
389+
case NGTCP2_FLOW_CONTROL_ERROR:
390+
return "FLOW_CONTROL_ERROR";
391+
case NGTCP2_STREAM_LIMIT_ERROR:
392+
return "STREAM_LIMIT_ERROR";
393+
case NGTCP2_STREAM_STATE_ERROR:
394+
return "STREAM_STATE_ERROR";
395+
case NGTCP2_FINAL_SIZE_ERROR:
396+
return "FINAL_SIZE_ERROR";
397+
case NGTCP2_FRAME_ENCODING_ERROR:
398+
return "FRAME_ENCODING_ERROR";
399+
case NGTCP2_TRANSPORT_PARAMETER_ERROR:
400+
return "TRANSPORT_PARAMETER_ERROR";
401+
case NGTCP2_CONNECTION_ID_LIMIT_ERROR:
402+
return "CONNECTION_ID_LIMIT_ERROR";
403+
case NGTCP2_PROTOCOL_VIOLATION:
404+
return "PROTOCOL_VIOLATION";
405+
case NGTCP2_INVALID_TOKEN:
406+
return "INVALID_TOKEN";
407+
case NGTCP2_APPLICATION_ERROR:
408+
return "APPLICATION_ERROR";
409+
case NGTCP2_CRYPTO_BUFFER_EXCEEDED:
410+
return "CRYPTO_BUFFER_EXCEEDED";
411+
case NGTCP2_KEY_UPDATE_ERROR:
412+
return "KEY_UPDATE_ERROR";
413+
case NGTCP2_AEAD_LIMIT_REACHED:
414+
return "AEAD_LIMIT_REACHED";
415+
case NGTCP2_NO_VIABLE_PATH:
416+
return "NO_VIABLE_PATH";
417+
case NGTCP2_VERSION_NEGOTIATION_ERROR:
418+
return "VERSION_NEGOTIATION_ERROR";
419+
default:
420+
return nullptr;
421+
}
422+
}
423+
366424
MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
367425
if ((type() == Type::TRANSPORT && code() == NGTCP2_NO_ERROR) ||
368426
(type() == Type::APPLICATION &&
@@ -384,6 +442,7 @@ MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
384442
type_str,
385443
BigInt::NewFromUnsigned(env->isolate(), code()),
386444
Undefined(env->isolate()),
445+
Undefined(env->isolate()),
387446
};
388447

389448
// Note that per the QUIC specification, the reason, if present, is
@@ -397,6 +456,13 @@ MaybeLocal<Value> QuicError::ToV8Value(Environment* env) const {
397456
return {};
398457
}
399458

459+
// Attach a human-readable name for known wire codes (RFC 9000 sec. 20.1
460+
// names and OpenSSL TLS alert descriptions for CRYPTO_ERROR). Unknown
461+
// codes leave the slot as undefined.
462+
if (const char* n = name()) {
463+
argv[3] = BindingData::Get(env).error_name_string(n);
464+
}
465+
400466
return Array::New(env->isolate(), argv, arraysize(argv)).As<Value>();
401467
}
402468

src/quic/data.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ class QuicError final : public MemoryRetainer {
265265
bool is_crypto_error() const;
266266
std::optional<int> get_crypto_error() const;
267267

268+
// Returns a human-readable name for this error if known, or nullptr
269+
const char* name() const;
270+
268271
// Note that since application errors are application-specific and we
269272
// don't know which application is being used here, it is possible that
270273
// the comparing two different QuicError instances from different applications

src/quic/session.cc

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3065,7 +3065,7 @@ void Session::CheckStreamIdleTimeout(uint64_t now) {
30653065
// Without this, the peer's stream sits orphaned until the
30663066
// session closes.
30673067
auto error =
3068-
QuicError::ForTransport(NGTCP2_ERR_PROTO, "stream idle timeout");
3068+
QuicError::ForNgtcp2Error(NGTCP2_ERR_PROTO, "stream idle timeout");
30693069
ShutdownStream(id, error);
30703070
stream->Destroy(error);
30713071
STAT_INCREMENT(Stats, streams_idle_timed_out);
@@ -3451,12 +3451,21 @@ void Session::EmitClose(const QuicError& error) {
34513451
Integer::New(env()->isolate(), static_cast<int>(error.type())),
34523452
BigInt::NewFromUnsigned(env()->isolate(), error.code()),
34533453
Undefined(env()->isolate()),
3454+
Undefined(env()->isolate()),
34543455
};
34553456
if (error.reason().length() > 0 &&
34563457
!ToV8Value(env()->context(), error.reason()).ToLocal(&argv[2])) {
34573458
return;
34583459
}
34593460

3461+
// Attach a human-readable name for known wire codes (RFC 9000 sec. 20.1
3462+
// names and OpenSSL TLS alert descriptions for CRYPTO_ERROR). Unknown
3463+
// codes leave the slot as undefined. See QuicError::name() for the
3464+
// matching path on stream-level errors.
3465+
if (const char* n = error.name()) {
3466+
argv[3] = BindingData::Get(env()).error_name_string(n);
3467+
}
3468+
34603469
MakeCallback(
34613470
BindingData::Get(env()).session_close_callback(), arraysize(argv), argv);
34623471

0 commit comments

Comments
 (0)