Skip to content

Commit cd7e276

Browse files
committed
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 <pimterry@gmail.com>
1 parent c612f35 commit cd7e276

10 files changed

Lines changed: 272 additions & 43 deletions

File tree

doc/api/quic.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1418,6 +1418,30 @@ will be silently dropped and `0n` returned. The local
14181418
`maxDatagramFrameSize` transport parameter (default: `1200` bytes) controls
14191419
what this endpoint advertises to the peer as its own maximum.
14201420

1421+
### `session.servername`
1422+
1423+
<!-- YAML
1424+
added: REPLACEME
1425+
-->
1426+
1427+
* Type: {string|undefined}
1428+
1429+
The SNI (Server Name Indication) host name associated with the session, or
1430+
`undefined` if none was set. On a client this is the `servername` requested via
1431+
[`quic.connect()`][]. On a server it is the name sent by the peer.
1432+
1433+
### `session.alpnProtocol`
1434+
1435+
<!-- YAML
1436+
added: REPLACEME
1437+
-->
1438+
1439+
* Type: {string|undefined}
1440+
1441+
The negotiated ALPN protocol. On a server this is available synchronously as
1442+
soon as the session is surfaced to the `onsession` callback; on a client it is
1443+
`undefined` until the handshake completes.
1444+
14211445
### `session.certificate`
14221446

14231447
<!-- YAML
@@ -3563,7 +3587,11 @@ added: v23.8.0
35633587
* `this` {quic.QuicEndpoint}
35643588
* `session` {quic.QuicSession}
35653589

3566-
The callback function that is invoked when a new session is initiated by a remote peer.
3590+
The callback function that is invoked when a new server session is initiated by
3591+
a remote peer. It is called once the peer's TLS `ClientHello` has been
3592+
processed, so the negotiated TLS parameters are immediately available when
3593+
the callback runs. Sessions whose handshake is rejected before this point are
3594+
never surfaced.
35673595

35683596
### Callback: `OnStreamCallback`
35693597

lib/internal/quic/quic.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -744,7 +744,9 @@ setCallbacks({
744744
this[kOwner][kFinishClose](context, status);
745745
},
746746
/**
747-
* Called when the QuicEndpoint C++ handle receives a new server-side session
747+
* Called when a new server session is surfaced. The emit happens once the
748+
* session's ClientHello has been processed, so its servername/protocol
749+
* getters are already readable.
748750
* @param {object} session The QuicSession C++ handle
749751
*/
750752
onSessionNew(session) {
@@ -2877,6 +2879,26 @@ class QuicSession {
28772879
}
28782880
}
28792881

2882+
/**
2883+
* The SNI servername, or undefined when none was sent.
2884+
* @type {string|undefined}
2885+
*/
2886+
get servername() {
2887+
assertIsQuicSession(this);
2888+
if (this.destroyed) return undefined;
2889+
return this.#handle.getServername();
2890+
}
2891+
2892+
/**
2893+
* The negotiated ALPN protocol.
2894+
* @type {string|undefined}
2895+
*/
2896+
get alpnProtocol() {
2897+
assertIsQuicSession(this);
2898+
if (this.destroyed) return undefined;
2899+
return this.#handle.getAlpnProtocol();
2900+
}
2901+
28802902
/** @type {OnDatagramCallback} */
28812903
get ondatagram() {
28822904
assertIsQuicSession(this);

src/quic/endpoint.cc

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -814,8 +814,8 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> session) {
814814
// For server sessions, associate the client's original DCID (ocid) so
815815
// that 0-RTT packets arriving in a separate UDP datagram can be routed
816816
// to this session. This must happen after the session is added (so
817-
// FindSession can resolve the mapping) but before EmitNewSession (which
818-
// runs JS and may yield to libuv, allowing the 0-RTT packet to arrive).
817+
// FindSession can resolve the mapping) and before any JS runs (which
818+
// may yield to libuv, allowing the 0-RTT packet to arrive).
819819
if (session->is_server() && session->config().ocid) {
820820
AssociateCID(session->config().ocid, session->config().scid);
821821
}
@@ -825,22 +825,22 @@ void Endpoint::AddSession(const CID& cid, BaseObjectPtr<Session> session) {
825825
if (session->is_server() && session->config().retry_scid) {
826826
AssociateCID(session->config().retry_scid, session->config().scid);
827827
}
828-
// Increment the primary session count and ref the handle BEFORE
829-
// EmitNewSession. EmitNewSession calls into JS, which may close/destroy
830-
// the session synchronously. The session's ~Impl calls RemoveSession
831-
// which decrements the count. If we increment after EmitNewSession,
832-
// RemoveSession would see count=0 and the count would be permanently
833-
// off by one.
828+
// Increment the primary session count and ref the handle BEFORE any
829+
// JS can run for this session (the deferred EmitNewSession, or packet
830+
// processing callbacks). JS may close/destroy the session
831+
// synchronously; the session's ~Impl calls RemoveSession which
832+
// decrements the count. If we incremented after, RemoveSession would
833+
// see count=0 and the count would be permanently off by one.
834834
if (primary_session_count_++ == 0) {
835835
idle_timer_.Stop();
836836
udp_.Ref();
837837
}
838838
if (session->is_server()) {
839839
STAT_INCREMENT(Stats, server_sessions);
840-
// We only emit the new session event for server sessions.
841-
EmitNewSession(session);
842-
// It is important to note that the session may be closed/destroyed
843-
// when it is emitted here.
840+
// Note that we don't emit new sessions here - that's deferred until the
841+
// ClientHello has been processed (see Session::ReadPacket), so the
842+
// session is exposed to JS only once its SNI/ALPN are known and invalid
843+
// handshakes never surface.
844844
} else {
845845
STAT_INCREMENT(Stats, client_sessions);
846846
}
@@ -1980,6 +1980,12 @@ void Endpoint::EmitNewSession(const BaseObjectPtr<Session>& session) {
19801980
// the call to MakeCallback. If that's the case, the session object still
19811981
// exists but it is in a destroyed state. Care should be taken accessing
19821982
// session after this point.
1983+
1984+
// Deliver any stream events that were held until the stream was setup,
1985+
// e.g. 0-RTT streams from the first flight.
1986+
if (!session->is_destroyed()) {
1987+
session->ReplayDeferredEmits();
1988+
}
19831989
}
19841990

19851991
void Endpoint::EmitClose(CloseContext context, int status) {

src/quic/session.cc

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,8 @@ uint64_t MaxDatagramPayload(uint64_t max_frame_size) {
184184
#define SESSION_JS_METHODS(V) \
185185
V(Destroy, destroy, SIDE_EFFECT) \
186186
V(GetRemoteAddress, getRemoteAddress, NO_SIDE_EFFECT) \
187+
V(GetServername, getServername, NO_SIDE_EFFECT) \
188+
V(GetAlpnProtocol, getAlpnProtocol, NO_SIDE_EFFECT) \
187189
V(GetLocalAddress, getLocalAddress, NO_SIDE_EFFECT) \
188190
V(GetCertificate, getCertificate, NO_SIDE_EFFECT) \
189191
V(GetEphemeralKeyInfo, getEphemeralKey, NO_SIDE_EFFECT) \
@@ -781,6 +783,8 @@ struct Session::Impl final : public MemoryRetainer {
781783
SocketAddress remote_address_;
782784
std::unique_ptr<Application> application_;
783785
StreamsMap streams_;
786+
// Emits deferred until after session setup is completed
787+
std::vector<std::function<void()>> deferred_emits_;
784788
TimerWrapHandle timer_;
785789
size_t send_scope_depth_ = 0;
786790
QuicError last_error_;
@@ -1001,6 +1005,36 @@ struct Session::Impl final : public MemoryRetainer {
10011005
session->Destroy();
10021006
}
10031007

1008+
// The SNI servername from the TLS handshake; empty (-> undefined) only if
1009+
// none was sent.
1010+
JS_METHOD(GetServername) {
1011+
auto env = Environment::GetCurrent(args);
1012+
Session* session;
1013+
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
1014+
if (session->is_destroyed()) return;
1015+
auto sn = session->tls_session().servername();
1016+
if (sn.empty()) return;
1017+
Local<Value> ret;
1018+
if (ToV8Value(env->context(), sn).ToLocal(&ret)) {
1019+
args.GetReturnValue().Set(ret);
1020+
}
1021+
}
1022+
1023+
// The negotiated ALPN protocol. Undefined only for clients before the
1024+
// handshake is completed, as ALPN is mandatory for QUIC.
1025+
JS_METHOD(GetAlpnProtocol) {
1026+
auto env = Environment::GetCurrent(args);
1027+
Session* session;
1028+
ASSIGN_OR_RETURN_UNWRAP(&session, args.This());
1029+
if (session->is_destroyed()) return;
1030+
auto proto = session->tls_session().protocol();
1031+
if (proto.empty()) return;
1032+
Local<Value> ret;
1033+
if (ToV8Value(env->context(), proto).ToLocal(&ret)) {
1034+
args.GetReturnValue().Set(ret);
1035+
}
1036+
}
1037+
10041038
JS_METHOD(GetRemoteAddress) {
10051039
auto env = Environment::GetCurrent(args);
10061040
Session* session;
@@ -2190,6 +2224,12 @@ const Session::Options& Session::options() const {
21902224
void Session::EmitQlog(uint32_t flags, std::string_view data) {
21912225
if (!env()->can_call_into_js()) return;
21922226

2227+
if (!is_destroyed() && must_defer_emits()) {
2228+
QueueDeferredEmit(
2229+
[this, flags, held = std::string(data)]() { EmitQlog(flags, held); });
2230+
return;
2231+
}
2232+
21932233
bool fin = (flags & NGTCP2_QLOG_WRITE_FLAG_FIN) != 0;
21942234

21952235
// Fun fact... ngtcp2 does not emit the final qlog statement until the
@@ -2312,6 +2352,16 @@ bool Session::ReadPacket(const uint8_t* data,
23122352
// Process deferred operations that couldn't run inside callback
23132353
// scopes (e.g., HTTP/3 GOAWAY handling that calls into JS).
23142354
application().PostReceive();
2355+
// Surface a server session to JS once its ClientHello has been
2356+
// processed (OnSelectAlpn fired: SNI + ALPN are known and reliable).
2357+
// Held first-flight events - including 0-RTT request streams - replay
2358+
// at emit. The !wrapped guard makes this fire exactly once, on
2359+
// whichever packet completes the ClientHello (so a multi-datagram
2360+
// ClientHello is handled correctly).
2361+
if (is_server() && hello_processed_ && !impl_->state()->wrapped &&
2362+
!is_destroyed()) {
2363+
endpoint().EmitNewSession(BaseObjectPtr<Session>(this));
2364+
}
23152365
}
23162366
return true;
23172367
}
@@ -2975,6 +3025,30 @@ void Session::set_wrapped() {
29753025
impl_->state()->wrapped = 1;
29763026
}
29773027

3028+
bool Session::must_defer_emits() const {
3029+
// Server sessions are surfaced to JS (via the deferred new-session emit)
3030+
// only after the ClientHello has been processed and wrapped; anything
3031+
// emitted before then has no JS wrapper to receive it and must be held
3032+
// for replay.
3033+
return is_server() && !impl_->state()->wrapped;
3034+
}
3035+
3036+
void Session::QueueDeferredEmit(std::function<void()> fn) {
3037+
impl_->deferred_emits_.emplace_back(std::move(fn));
3038+
}
3039+
3040+
void Session::ReplayDeferredEmits() {
3041+
if (is_destroyed()) return;
3042+
DCHECK(impl_->state()->wrapped);
3043+
// Runs synchronously immediately after the new-session callback
3044+
// returns (still within first-flight processing).
3045+
auto emits = std::move(impl_->deferred_emits_);
3046+
for (auto& emit : emits) {
3047+
if (is_destroyed()) return;
3048+
emit();
3049+
}
3050+
}
3051+
29783052
void Session::set_priority_supported(bool on) {
29793053
DCHECK(!is_destroyed());
29803054
impl_->state()->priority_supported = on ? 1 : 0;
@@ -3284,6 +3358,10 @@ bool Session::HandshakeCompleted() {
32843358

32853359
Debug(this, "Session handshake completed");
32863360
impl_->state()->handshake_completed = 1;
3361+
// This implies fully completing a handshake without setting hello_processed
3362+
// (set during ALPN negotiation). Should be impossible unless ALPN flow is
3363+
// changed drastically, but good to check as it'd lose sessions.
3364+
DCHECK(!is_server() || hello_processed_);
32873365

32883366
STAT_RECORD_TIMESTAMP(Stats, handshake_completed_at);
32893367
SetStreamOpenAllowed();
@@ -3482,6 +3560,7 @@ void Session::set_max_datagram_size(uint16_t size) {
34823560

34833561
void Session::EmitGoaway(stream_id last_stream_id) {
34843562
if (is_destroyed()) return;
3563+
if (DeferEmit([this, last_stream_id] { EmitGoaway(last_stream_id); })) return;
34853564
if (!env()->can_call_into_js()) return;
34863565

34873566
CallbackScope<Session> cb_scope(this);
@@ -3496,6 +3575,14 @@ void Session::EmitGoaway(stream_id last_stream_id) {
34963575

34973576
void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
34983577
DCHECK(!is_destroyed());
3578+
3579+
if (must_defer_emits()) {
3580+
QueueDeferredEmit([this, datagram = std::move(datagram), flag]() mutable {
3581+
EmitDatagram(std::move(datagram), flag);
3582+
});
3583+
return;
3584+
}
3585+
34993586
if (!env()->can_call_into_js()) return;
35003587

35013588
CallbackScope<Session> cbv_scope(this);
@@ -3511,6 +3598,8 @@ void Session::EmitDatagram(Store&& datagram, DatagramReceivedFlags flag) {
35113598
void Session::EmitDatagramStatus(datagram_id id, quic::DatagramStatus status) {
35123599
DCHECK(!is_destroyed());
35133600

3601+
if (DeferEmit([this, id, status] { EmitDatagramStatus(id, status); })) return;
3602+
35143603
if (!env()->can_call_into_js()) return;
35153604

35163605
CallbackScope<Session> cb_scope(this);
@@ -3672,6 +3761,7 @@ void Session::EmitSessionTicket(Store&& ticket) {
36723761

36733762
void Session::EmitApplication() {
36743763
if (is_destroyed()) return;
3764+
if (DeferEmit([this] { EmitApplication(); })) return;
36753765
if (!env()->can_call_into_js()) return;
36763766

36773767
if (!has_application()) {
@@ -3742,6 +3832,10 @@ void Session::EmitNewToken(const uint8_t* token, size_t len) {
37423832
void Session::EmitStream(const BaseObjectWeakPtr<Stream>& stream) {
37433833
DCHECK(!is_destroyed());
37443834

3835+
if (DeferEmit([this, stream] { EmitStream(stream); })) return;
3836+
3837+
if (!stream) return;
3838+
37453839
if (!env()->can_call_into_js()) return;
37463840
CallbackScope<Session> cb_scope(this);
37473841

@@ -3797,6 +3891,14 @@ void Session::EmitVersionNegotiation(const ngtcp2_pkt_hd& hd,
37973891

37983892
void Session::EmitOrigins(std::vector<std::string>&& origins) {
37993893
DCHECK(!is_destroyed());
3894+
3895+
if (must_defer_emits()) {
3896+
QueueDeferredEmit([this, origins = std::move(origins)]() mutable {
3897+
EmitOrigins(std::move(origins));
3898+
});
3899+
return;
3900+
}
3901+
38003902
if (!HasListenerFlag(impl_->state()->listener_flags,
38013903
SessionListenerFlags::ORIGIN))
38023904
return;
@@ -3822,11 +3924,17 @@ void Session::EmitOrigins(std::vector<std::string>&& origins) {
38223924

38233925
void Session::EmitKeylog(const char* line) {
38243926
DCHECK(!is_destroyed());
3927+
3928+
if (must_defer_emits()) {
3929+
QueueDeferredEmit(
3930+
[this, str = std::string(line)]() { EmitKeylog(str.c_str()); });
3931+
return;
3932+
}
3933+
38253934
if (!env()->can_call_into_js()) return;
38263935

3827-
auto str = std::string(line);
38283936
Local<Value> argv[] = {Undefined(env()->isolate())};
3829-
if (!ToV8Value(env()->context(), str).ToLocal(&argv[0])) {
3937+
if (!ToV8Value(env()->context(), std::string(line)).ToLocal(&argv[0])) {
38303938
Debug(this, "Failed to convert keylog line to V8 string");
38313939
return;
38323940
}

src/quic/session.h

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,11 +536,32 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
536536
// before the handshake completes.
537537
void PopulateEarlyTransportParamsState();
538538

539+
void set_hello_processed() { hello_processed_ = true; }
540+
539541
// It's a terrible name but "wrapped" here means that the Session has been
540542
// passed out to JavaScript and should be "wrapped" by whatever handler is
541543
// defined there to manage it.
542544
void set_wrapped();
543545

546+
// True while JS emits must be held for later replay, before the handshake
547+
// is complete and the server session event has been emitted.
548+
bool must_defer_emits() const;
549+
550+
// Replays, in order, any emits held while must_defer_emits() was true.
551+
// Called synchronously right after the new-session emit.
552+
void ReplayDeferredEmits();
553+
554+
// Queues fn to be replayed by ReplayDeferredEmits(). Out-of-line so the
555+
// header does not need the full Impl definition.
556+
void QueueDeferredEmit(std::function<void()> fn);
557+
558+
template <typename F>
559+
bool DeferEmit(F&& fn) {
560+
if (!must_defer_emits()) return false;
561+
QueueDeferredEmit(std::forward<F>(fn));
562+
return true;
563+
}
564+
544565
enum class CloseMethod : uint8_t {
545566
// Immediate close with a roundtrip through JavaScript, causing all
546567
// currently opened streams to be closed. An attempt will be made to
@@ -656,6 +677,8 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source {
656677
};
657678
Flags flags_;
658679

680+
bool hello_processed_ = false;
681+
659682
QuicConnectionPointer connection_;
660683
std::unique_ptr<TLSSession> tls_session_;
661684
friend struct NgTcp2CallbackScope;

0 commit comments

Comments
 (0)