Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Commit 6d6cd45

Browse files
jasnelladuh95
authored andcommitted
quic: improve peer cert verification
On the client, add verifyPeer: 'auto', 'strict', and 'manual' modes. The 'strict' mode will reject invalid certs at the handshake layer, while the 'manual' mode allows the application to inspect the peer cert and decide whether to trust it or not. The 'auto' mode is the default and will reject invalid certs at a middle layer after the onhandshake event. Signed-off-by: James M Snell <jasnell@gmail.com> Assisted-by: Opencode/Opus 4.6 PR-URL: #63483 Backport-PR-URL: #64675 Reviewed-By: Stephen Belanger <admin@stephenbelanger.com> Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
1 parent dbe0b37 commit 6d6cd45

70 files changed

Lines changed: 273 additions & 25 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎doc/api/quic.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3018,6 +3018,32 @@ value, PING frames will be sent automatically to keep the connection alive
30183018
before the idle timeout fires. The value should be less than the effective
30193019
idle timeout (`maxIdleTimeout` transport parameter) to be useful.
30203020

3021+
#### `sessionOptions.verifyPeer` (client only)
3022+
3023+
* Type: {string} One of `'strict'`, `'auto'`, or `'manual'`.
3024+
***Default:**`'auto'`
3025+
3026+
Controls how the client handles server certificate validation:
3027+
3028+
*`'strict'` — OpenSSL aborts the TLS handshake immediately if the server's
3029+
certificate fails validation. The `session.opened` promise rejects with a
3030+
TLS error. The application cannot inspect the certificate or the error
3031+
details. This is the most secure mode.
3032+
3033+
*`'auto'` — The TLS handshake completes regardless of validation result.
3034+
If validation fails, the `session.opened` promise is rejected with an error
3035+
containing the validation reason, and the session is destroyed. The
3036+
`onhandshake` callback (if set) fires before rejection, allowing diagnostic
3037+
logging. This is the default and matches the behavior of `tls.connect()`
3038+
with `rejectUnauthorized: true`.
3039+
3040+
*`'manual'` — The TLS handshake completes regardless of validation result.
3041+
The `session.opened` promise resolves with the handshake info, which includes
3042+
`validationErrorReason` and `validationErrorCode` if validation failed. The
3043+
application is responsible for checking these values and deciding whether to
3044+
continue. Use this mode for custom validation logic, certificate pinning, or
3045+
intentionally accepting self-signed certificates.
3046+
30213047
#### `sessionOptions.servername` (client only)
30223048

30233049
<!-- YAML

‎lib/internal/quic/quic.js‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@ const {
183183
kGoaway,
184184
kHandshake,
185185
kHandshakeCompleted,
186+
kVerifyPeer,
186187
kHeaders,
187188
kOwner,
188189
kRemoveSession,
@@ -382,6 +383,7 @@ const endpointRegistry = new SafeSet();
382383
* @property {number} [version] The QUIC version
383384
* @property {number} [minVersion] The minimum acceptable QUIC version
384385
* @property {'use'|'ignore'|'default'} [preferredAddressPolicy] The preferred address policy
386+
* @property {'strict'|'auto'|'manual'} [verifyPeer='auto'] Peer certificate verification policy (client only)
385387
* @property {ApplicationOptions} [application] The application options
386388
* @property {TransportParams} [transportParams] The transport parameters
387389
* @property {string} [servername] The server name identifier (client only)
@@ -2628,6 +2630,11 @@ class QuicSession {
26282630
onkeylog: undefined,
26292631
onqlog: undefined,
26302632
pendingQlog: undefined,
2633+
// Default to 'manual' (no auto-rejection). Client sessions override
2634+
// this via kVerifyPeer in kConnect. Server sessions keep 'manual'
2635+
// because server-side cert validation is handled by rejectUnauthorized
2636+
// at the C++ level.
2637+
verifyPeer: 'manual',
26312638
handshakeInfo: undefined,
26322639
/** @type {QuicSessionPath|undefined} */
26332640
path: undefined,
@@ -3844,6 +3851,22 @@ class QuicSession {
38443851
safeCallbackInvoke(inner.onhandshake,this,info);
38453852
}
38463853

3854+
// In 'auto' mode, reject the connection if peer certificate validation
3855+
// failed. In 'manual' mode, resolve regardless and let the application
3856+
// decide. In 'strict' mode, the handshake already failed at the C++
3857+
// level (SSL_VERIFY_PEER) so we won't reach here.
3858+
if(inner.verifyPeer==='auto'&&validationErrorReason!==undefined){
3859+
consterr=newERR_QUIC_TRANSPORT_ERROR(
3860+
0,`Peer certificate validation failed: ${validationErrorReason}`+
3861+
` [${validationErrorCode}]`);
3862+
inner.pendingOpen.reject?.(err);
3863+
inner.pendingOpen.resolve=undefined;
3864+
inner.pendingOpen.reject=undefined;
3865+
inner.handshakeCompleted=true;
3866+
this.destroy();
3867+
return;
3868+
}
3869+
38473870
inner.pendingOpen.resolve?.(info);
38483871
inner.pendingOpen.resolve=undefined;
38493872
inner.pendingOpen.reject=undefined;
@@ -3855,6 +3878,14 @@ class QuicSession {
38553878
returnthis.#inner.handshakeCompleted;
38563879
}
38573880

3881+
get[kVerifyPeer](){
3882+
returnthis.#inner.verifyPeer;
3883+
}
3884+
3885+
set[kVerifyPeer](value){
3886+
this.#inner.verifyPeer=value;
3887+
}
3888+
38583889
/**
38593890
* @param {object} handle
38603891
* @param {number} direction
@@ -4306,6 +4337,10 @@ class QuicEndpoint {
43064337
// Set callbacks before any async work to avoid missing events
43074338
// that fire during or immediately after the handshake.
43084339
applyCallbacks(session,options);
4340+
// Store the verifyPeer policy for use in the handshake handler.
4341+
if(options.verifyPeer!==undefined){
4342+
session[kVerifyPeer]=options.verifyPeer;
4343+
}
43094344
returnsession;
43104345
}
43114346

@@ -4959,6 +4994,7 @@ function processSessionOptions(options, config = kEmptyObject) {
49594994
datagramDropPolicy ='drop-oldest',
49604995
drainingPeriodMultiplier =3,
49614996
maxDatagramSendAttempts =5,
4997+
verifyPeer ='auto',
49624998
// HTTP/3 application-specific options. Nested under `application`
49634999
// to separate protocol-specific settings from transport-level ones.
49645000
application =kEmptyObject,
@@ -5005,6 +5041,9 @@ function processSessionOptions(options, config = kEmptyObject) {
50055041
validateOneOf(datagramDropPolicy,'options.datagramDropPolicy',
50065042
['drop-oldest','drop-newest']);
50075043

5044+
validateOneOf(verifyPeer,'options.verifyPeer',
5045+
['strict','auto','manual']);
5046+
50085047
validateInteger(drainingPeriodMultiplier,'options.drainingPeriodMultiplier',
50095048
3,255);
50105049

@@ -5054,7 +5093,14 @@ function processSessionOptions(options, config = kEmptyObject) {
50545093
preferredAddressIpv4: preferredAddressIpv4?.[kSocketAddressHandle],
50555094
preferredAddressIpv6: preferredAddressIpv6?.[kSocketAddressHandle],
50565095
},
5057-
tls: processTlsOptions(options,forServer),
5096+
tls: {
5097+
...processTlsOptions(options,forServer),
5098+
// Forward strict mode to C++ so SSL_VERIFY_PEER is set on the
5099+
// client SSL_CTX. For 'auto' and 'manual' modes, the handshake
5100+
// completes regardless and the result is handled in JS.
5101+
verifyPeerStrict: verifyPeer==='strict',
5102+
},
5103+
verifyPeer,
50585104
qlog,
50595105
maxPayloadSize,
50605106
unacknowledgedPacketThreshold,

‎lib/internal/quic/symbols.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const kFinishClose = Symbol('kFinishClose');
3838
constkGoaway=Symbol('kGoaway');
3939
constkHandshake=Symbol('kHandshake');
4040
constkHandshakeCompleted=Symbol('kHandshakeCompleted');
41+
constkVerifyPeer=Symbol('kVerifyPeer');
4142
constkHeaders=Symbol('kHeaders');
4243
constkKeylog=Symbol('kKeylog');
4344
constkListen=Symbol('kListen');
@@ -70,6 +71,7 @@ module.exports = {
7071
kGoaway,
7172
kHandshake,
7273
kHandshakeCompleted,
74+
kVerifyPeer,
7375
kHeaders,
7476
kInspect,
7577
kKeylog,

‎src/quic/bindingdata.h‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ class SessionManager;
164164
V(unacknowledged_packet_threshold, "unacknowledgedPacketThreshold") \
165165
V(validate_address, "validateAddress") \
166166
V(verify_client, "verifyClient") \
167+
V(verify_peer_strict, "verifyPeerStrict") \
167168
V(verify_private_key, "verifyPrivateKey") \
168169
V(version, "version")
169170

‎src/quic/tlscontext.cc‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,14 @@ SSLCtxPointer TLSContext::Initialize(Environment* env) {
500500
SSL_CTX_set_session_cache_mode(
501501
ctx.get(), SSL_SESS_CACHE_CLIENT | SSL_SESS_CACHE_NO_INTERNAL);
502502
SSL_CTX_sess_set_new_cb(ctx.get(), OnNewSession);
503+
504+
// In strict mode, set SSL_VERIFY_PEER so OpenSSL aborts the
505+
// handshake if the server's certificate fails validation. In
506+
// non-strict modes, verification still occurs but the handshake
507+
// completes regardless — the result is surfaced to JS.
508+
if (options_.verify_peer_strict) {
509+
SSL_CTX_set_verify(ctx.get(), SSL_VERIFY_PEER, nullptr);
510+
}
503511
break;
504512
}
505513
}
@@ -706,7 +714,8 @@ Maybe<TLSContext::Options> TLSContext::Options::From(Environment* env,
706714
env, &options, params, state.name##_string())
707715

708716
if (!SET(verify_client) || !SET(reject_unauthorized) ||
709-
!SET(enable_early_data) || !SET(enable_tls_trace) || !SET(alpn) ||
717+
!SET(verify_peer_strict) || !SET(enable_early_data) ||
718+
!SET(enable_tls_trace) || !SET(alpn) ||
710719
!SET(servername) || !SET(ciphers) || !SET(groups) ||
711720
!SET(verify_private_key) || !SET(keylog) || !SET(port) ||
712721
!SET(authoritative) || !SET_VECTOR(crypto::KeyObjectData, keys) ||
@@ -730,6 +739,8 @@ std::string TLSContext::Options::ToString() const {
730739
(verify_client ? std::string("yes") : std::string("no"));
731740
res += prefix + "reject unauthorized: " +
732741
(reject_unauthorized ? std::string("yes") : std::string("no"));
742+
res += prefix + "verify peer strict: " +
743+
(verify_peer_strict ? std::string("yes") : std::string("no"));
733744
res += prefix + "enable early data: " +
734745
(enable_early_data ? std::string("yes") : std::string("no"));
735746
res += prefix + "enable_tls_trace: " +

‎src/quic/tlscontext.h‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,14 @@ class TLSContext final : public MemoryRetainer,
207207
// This option is only used by the server side.
208208
bool reject_unauthorized = true;
209209

210+
// When true, the client will set SSL_VERIFY_PEER so that OpenSSL
211+
// aborts the handshake if the server's certificate fails validation.
212+
// This is the "strict" verify_peer mode. When false (the default),
213+
// the handshake completes regardless and VerifyPeerIdentity is
214+
// called after to surface errors to JS. This option is only used
215+
// by the client side.
216+
bool verify_peer_strict = false;
217+
210218
// When true (the default), the server accepts 0-RTT early data
211219
// from clients with valid session tickets. When false, early data
212220
// is disabled and clients must complete a full handshake before

‎test/common/quic.mjs‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ async function listen(callback, options = {}) {
4444
asyncfunctionconnect(address,options={}){
4545
const{
4646
alpn ='quic-test',
47+
// Test helper defaults to 'manual' because tests use self-signed
48+
// certs without a CA. Tests that want to verify cert validation
49+
// behavior should set verifyPeer explicitly.
50+
verifyPeer ='manual',
4751
...rest
4852
}=options;
49-
returnquic.connect(address,{ alpn, ...rest});
53+
returnquic.connect(address,{ alpn,verifyPeer,...rest});
5054
}
5155

5256
export{

‎test/parallel/test-quic-address-validation.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const serverEndpoint = await listen(mustCall(async (serverSession) => {
3737

3838
constclientSession=awaitconnect(serverEndpoint.address,{
3939
alpn: 'quic-test',
40+
verifyPeer: 'manual',
4041
servername: 'localhost',
4142
});
4243

‎test/parallel/test-quic-alpn-h3.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ notStrictEqual(serverEndpoint.address, undefined);
3535

3636
constclientSession=awaitconnect(serverEndpoint.address,{
3737
servername: 'localhost',
38+
verifyPeer: 'manual',
3839
});
3940

4041
asyncfunctioncheckClient(){

‎test/parallel/test-quic-alpn.mjs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ notStrictEqual(serverEndpoint.address, undefined);
4141
constclientSession=awaitconnect(serverEndpoint.address,{
4242
alpn: 'proto-b',
4343
servername: 'localhost',
44+
verifyPeer: 'manual',
4445
});
4546

4647
awaitPromise.all([serverOpened.promise,checkSession(clientSession)]);

0 commit comments

Comments
 (0)