Skip to content

Commit 6a97b09

Browse files
pimterryaduh95
authored andcommitted
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 0eff3e2 commit 6a97b09

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
letmessage='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
constresetCode=getQuicStreamState(this[kOwner]).resetCode;
933934
if(resetCode!==undefined&&resetCode>0n){
934-
error=newERR_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+
functionquicErrorMessage(prefix,errorCode,reason,errorName){
1070+
letmsg=`${prefix} `;
1071+
msg+=errorName ? `${errorName} (${errorCode})` : `${errorCode}`;
1072+
if(reason)msg+=`: ${reason}`;
1073+
returnmsg;
1074+
}
1075+
1076+
functionmakeQuicError(code,prefix,type,errorCode,reason,errorName){
1077+
consterr=newQuicError(
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+
returnerr;
1084+
}
1085+
10591086
functionconvertQuicError(error){
10601087
consttype=error[0];
10611088
constcode=error[1];
10621089
constreason=error[2];
1090+
consterrorName=error[3];
10631091
switch(type){
10641092
case'transport':
1065-
returnnewERR_QUIC_TRANSPORT_ERROR(code,reason);
1093+
returnmakeQuicError('ERR_QUIC_TRANSPORT_ERROR',
1094+
'QUIC transport error',
1095+
'transport',code,reason,errorName);
10661096
case'application':
1067-
returnnewERR_QUIC_APPLICATION_ERROR(code,reason);
1097+
returnmakeQuicError('ERR_QUIC_APPLICATION_ERROR',
1098+
'QUIC application error',
1099+
'application',code,reason,errorName);
10681100
case'version_negotiation':
10691101
returnnewERR_QUIC_VERSION_NEGOTIATION_ERROR();
10701102
default:
1071-
returnnewERR_QUIC_TRANSPORT_ERROR(code,reason);
1103+
returnmakeQuicError('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
case0: /* Transport Error */
3614-
this.destroy(newERR_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
case1: /* Application Error */
3617-
this.destroy(newERR_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
case2: /* Version Negotiation Error */
36203659
this.destroy(newERR_QUIC_VERSION_NEGOTIATION_ERROR());
@@ -3623,7 +3662,9 @@ class QuicSession {
36233662
this.destroy();
36243663
break;
36253664
default:
3626-
this.destroy(newERR_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-
consterr=newERR_QUIC_TRANSPORT_ERROR(
3878-
0,`Peer certificate validation failed: ${validationErrorReason}`+
3879-
` [${validationErrorCode}]`);
3918+
consterr=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(constchar* 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(constchar* 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<constchar*, 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
returncode() & ~NGTCP2_CRYPTO_ERROR;
364366
}
365367

368+
constchar* 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+
constchar* n = SSL_alert_desc_string_long(*alert);
375+
if (n != nullptr && std::string_view(n) != "unknown") return n;
376+
returnnullptr;
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) returnnullptr;
382+
switch (code()) {
383+
caseNGTCP2_NO_ERROR:
384+
return"NO_ERROR";
385+
caseNGTCP2_INTERNAL_ERROR:
386+
return"INTERNAL_ERROR";
387+
caseNGTCP2_CONNECTION_REFUSED:
388+
return"CONNECTION_REFUSED";
389+
caseNGTCP2_FLOW_CONTROL_ERROR:
390+
return"FLOW_CONTROL_ERROR";
391+
caseNGTCP2_STREAM_LIMIT_ERROR:
392+
return"STREAM_LIMIT_ERROR";
393+
caseNGTCP2_STREAM_STATE_ERROR:
394+
return"STREAM_STATE_ERROR";
395+
caseNGTCP2_FINAL_SIZE_ERROR:
396+
return"FINAL_SIZE_ERROR";
397+
caseNGTCP2_FRAME_ENCODING_ERROR:
398+
return"FRAME_ENCODING_ERROR";
399+
caseNGTCP2_TRANSPORT_PARAMETER_ERROR:
400+
return"TRANSPORT_PARAMETER_ERROR";
401+
caseNGTCP2_CONNECTION_ID_LIMIT_ERROR:
402+
return"CONNECTION_ID_LIMIT_ERROR";
403+
caseNGTCP2_PROTOCOL_VIOLATION:
404+
return"PROTOCOL_VIOLATION";
405+
caseNGTCP2_INVALID_TOKEN:
406+
return"INVALID_TOKEN";
407+
caseNGTCP2_APPLICATION_ERROR:
408+
return"APPLICATION_ERROR";
409+
caseNGTCP2_CRYPTO_BUFFER_EXCEEDED:
410+
return"CRYPTO_BUFFER_EXCEEDED";
411+
caseNGTCP2_KEY_UPDATE_ERROR:
412+
return"KEY_UPDATE_ERROR";
413+
caseNGTCP2_AEAD_LIMIT_REACHED:
414+
return"AEAD_LIMIT_REACHED";
415+
caseNGTCP2_NO_VIABLE_PATH:
416+
return"NO_VIABLE_PATH";
417+
caseNGTCP2_VERSION_NEGOTIATION_ERROR:
418+
return"VERSION_NEGOTIATION_ERROR";
419+
default:
420+
returnnullptr;
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 (constchar* n = name()) {
463+
argv[3] = BindingData::Get(env).error_name_string(n);
464+
}
465+
400466
returnArray::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
boolis_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+
constchar* 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 (constchar* 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)