From cd7e2762eb7a4b5f96973031ac4ca2d0486d7450 Mon Sep 17 00:00:00 2001 From: Tim Perry Date: Thu, 25 Jun 2026 16:10:51 +0200 Subject: [PATCH 1/2] quic: defer server session emit until TLS ClientHello is processed This ensures we don't fire session events for totally invalid TLS handshakes - fundamental errors, bad SNI/ALPN values, or anything else that our TLS config would reject. Instead, it means servers can access servername & alpnProtocol synchronously as soon as the event is fired - all key session data is available and it's immediately usable. We don't want to defer further to handshake completed, since that'd be an extra RT, and defeat 0RTT benefits entirely. ClientHello processed without errors is sufficient for now. This isn't a security mechanism. Existing structures will defer actually sending & receiving anything that's not marked explicitly as early data until the handshake completes anyway. Signed-off-by: Tim Perry --- doc/api/quic.md | 30 ++++- lib/internal/quic/quic.js | 24 +++- src/quic/endpoint.cc | 30 +++-- src/quic/session.cc | 112 +++++++++++++++++- src/quic/session.h | 23 ++++ src/quic/tlscontext.cc | 1 + test/parallel/test-quic-alpn-mismatch.mjs | 17 ++- .../test-quic-session-emit-ordering.mjs | 48 ++++++++ test/parallel/test-quic-sni-mismatch.mjs | 24 ++-- test/parallel/test-quic-zero-rtt.mjs | 6 +- 10 files changed, 272 insertions(+), 43 deletions(-) create mode 100644 test/parallel/test-quic-session-emit-ordering.mjs diff --git a/doc/api/quic.md b/doc/api/quic.md index d36825d6943264..ac3f5712ba2fa6 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -1418,6 +1418,30 @@ will be silently dropped and `0n` returned. The local `maxDatagramFrameSize` transport parameter (default: `1200` bytes) controls what this endpoint advertises to the peer as its own maximum. +### `session.servername` + + + +* Type: {string|undefined} + +The SNI (Server Name Indication) host name associated with the session, or +`undefined` if none was set. On a client this is the `servername` requested via +[`quic.connect()`][]. On a server it is the name sent by the peer. + +### `session.alpnProtocol` + + + +* Type: {string|undefined} + +The negotiated ALPN protocol. On a server this is available synchronously as +soon as the session is surfaced to the `onsession` callback; on a client it is +`undefined` until the handshake completes. + ### `session.certificate` -* Type: {string|undefined} +* Type: {string|boolean|null} -The SNI (Server Name Indication) host name associated with the session, or -`undefined` if none was set. On a client this is the `servername` requested via -[`quic.connect()`][]. On a server it is the name sent by the peer. +The SNI (Server Name Indication) host name associated with the session. This is +`null` before the client hello is processed. Once the hello has been +processed, this is either the host name string or `false` if the handshake +had no SNI. ### `session.alpnProtocol` @@ -1436,11 +1437,12 @@ The SNI (Server Name Indication) host name associated with the session, or added: REPLACEME --> -* Type: {string|undefined} +* Type: {string|null} -The negotiated ALPN protocol. On a server this is available synchronously as -soon as the session is surfaced to the `onsession` callback; on a client it is -`undefined` until the handshake completes. +The negotiated ALPN protocol. This is `null` before the client hello is +processed. Once ALPN has been negotiated, this is the protocol string. ALPN +is mandatory in QUIC so this is never `false` on successful connections, +unlike `node:tls` where this is optional. ### `session.certificate` diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 1b416f35c2218f..d5d512e0756aa6 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -2729,6 +2729,8 @@ class QuicSession { certificate: undefined, peerCertificate: undefined, ephemeralKeyInfo: undefined, + servername: undefined, + alpnProtocol: undefined, localTransportParams: undefined, remoteTransportParams: undefined, }; @@ -2880,23 +2882,35 @@ class QuicSession { } /** - * The SNI servername, or undefined when none was sent. - * @type {string|undefined} + * The SNI servername: `null` until known, then the host name string, or + * `false` if the handshake produced no SNI. + * @type {string|boolean|null} */ get servername() { assertIsQuicSession(this); - if (this.destroyed) return undefined; - return this.#handle.getServername(); + const inner = this.#inner; + if (inner.servername !== undefined) return inner.servername; + if (this.destroyed) return null; + // The handle returns null until the value is final; cache it only once it + // settles (to a string or `false`) so earlier reads re-query. + const value = this.#handle.getServername(); + if (value !== null) inner.servername = value; + return value; } /** - * The negotiated ALPN protocol. - * @type {string|undefined} + * The negotiated ALPN protocol: `null` until known, then the protocol + * string. ALPN is mandatory for QUIC, so there is no "no ALPN" case. + * @type {string|null} */ get alpnProtocol() { assertIsQuicSession(this); - if (this.destroyed) return undefined; - return this.#handle.getAlpnProtocol(); + const inner = this.#inner; + if (inner.alpnProtocol !== undefined) return inner.alpnProtocol; + if (this.destroyed) return null; + const value = this.#handle.getAlpnProtocol(); + if (value !== null) inner.alpnProtocol = value; + return value; } /** @type {OnDatagramCallback} */ diff --git a/src/quic/session.cc b/src/quic/session.cc index 83eab0d99eaa38..aad2d898cb9b27 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -1005,30 +1005,35 @@ struct Session::Impl final : public MemoryRetainer { session->Destroy(); } - // The SNI servername from the TLS handshake; empty (-> undefined) only if - // none was sent. + // The SNI servername: null until the TLS parameters are final, then the + // host name string, or false if the handshake produced no SNI. JS_METHOD(GetServername) { auto env = Environment::GetCurrent(args); Session* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); - if (session->is_destroyed()) return; + if (session->is_destroyed() || !session->tls_info_ready()) { + return args.GetReturnValue().SetNull(); + } auto sn = session->tls_session().servername(); - if (sn.empty()) return; + if (sn.empty()) return args.GetReturnValue().Set(false); Local ret; if (ToV8Value(env->context(), sn).ToLocal(&ret)) { args.GetReturnValue().Set(ret); } } - // The negotiated ALPN protocol. Undefined only for clients before the - // handshake is completed, as ALPN is mandatory for QUIC. + // The negotiated ALPN protocol: null until the TLS parameters are final, + // then the protocol string. JS_METHOD(GetAlpnProtocol) { auto env = Environment::GetCurrent(args); Session* session; ASSIGN_OR_RETURN_UNWRAP(&session, args.This()); - if (session->is_destroyed()) return; + if (session->is_destroyed() || !session->tls_info_ready()) { + return args.GetReturnValue().SetNull(); + } auto proto = session->tls_session().protocol(); - if (proto.empty()) return; + // QUIC requires ALPN + DCHECK(!proto.empty()); Local ret; if (ToV8Value(env->context(), proto).ToLocal(&ret)) { args.GetReturnValue().Set(ret); @@ -3033,6 +3038,12 @@ bool Session::must_defer_emits() const { return is_server() && !impl_->state()->wrapped; } +bool Session::tls_info_ready() const { + // hello_processed_ is set server-side, handshake_completed covers + // the client. Together they mark the point when SNI/ALPN are final. + return hello_processed_ || impl_->state()->handshake_completed; +} + void Session::QueueDeferredEmit(std::function fn) { impl_->deferred_emits_.emplace_back(std::move(fn)); } diff --git a/src/quic/session.h b/src/quic/session.h index e8663c511c0f7e..adac1d7d0f28fc 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -538,6 +538,9 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { void set_hello_processed() { hello_processed_ = true; } + // True once the negotiated TLS parameters (SNI, ALPN) are final. + bool tls_info_ready() const; + // It's a terrible name but "wrapped" here means that the Session has been // passed out to JavaScript and should be "wrapped" by whatever handler is // defined there to manage it.