Repository navigation
fix(ext/node): do not resume client TLS sessions unless requested - #36592
bartlomieju merged 6 commits into
Conversation
…noland#36228) Client TLS connections in Node.js are strictly stateless across connections unless options.session is explicitly passed or socket.setSession() is called. Previously in Deno (since denoland#33693), client configs shared a global session store that automatically resumed TLS sessions on subsequent connections to the same host. This broke TDS/PRELOGIN connections (e.g. mssql/tedious) and database connection pools where servers do not support session resumption. This wraps the client session store with NodeClientSessionStoreWrapper so that session retrieval is gated on an allow_resumption flag while newly issued sessions continue to be recorded for socket.getSession() and 'session' events. Closes denoland#36228. Closes denoland#27833. Refs denoland#33693.
Deno Individual Contributor License AgreementAll contributors have signed the CLA. Thank you! This is an automated message from CLA Assistant |
There was a problem hiding this comment.
Built this branch and verified it end to end. The repro from #36228 now matches Node exactly: Node v26.5.0 does 5 fresh handshakes, stable 2.9.5 resumes on 3 of them, this build does 5 fresh and still resumes when options.session is passed. unit_node::tls_test plus the node_compat client-resume, https-client-resume, and session-cache tests all pass locally. Nice find on the root cause.
One real issue inline (the autoSelectFamily reinit path skips the session validation), one nit inline. Also: drop (#36228) from the title, squash-merge appends the PR number on its own.
@bartlomieju flagging this one: it's biting us in production (mssql pools against RDS time out on every other connection).
| this._handle.setAlpnProtocols(options.ALPNProtocols); | ||
| } | ||
| if (this._session) { | ||
| this._handle.setSession(this._session); |
There was a problem hiding this comment.
This re-applies the session without the syntheticSessionMatches gate. setSession() stores this._session even when validation fails, so after an autoSelectFamily handle swap a session that failed the host:port check still flips allow_resumption on. Mirror setSession():
if (this._session && this._sessionReused) {
this._handle.setSession(this._session);
}|
|
||
| if (options.session) { | ||
| ssl.setSession(options.session); | ||
| if (syntheticSessionMatches(options.session, options)) { |
There was a problem hiding this comment.
nit: options here is _tlsOptions, which has no host/port, so the computed key is "<servername>:" and never matches a synthetic session (they always embed the port). Effectively dead code. Harmless for tls.connect(), which re-applies the session against kConnectOptions later, but worth pointing at the right options object or dropping the branch with a comment.
| this._handle.setAlpnProtocols(options.ALPNProtocols); | ||
| } | ||
| if (this._session) { | ||
| this._handle.setSession(this._session); |
There was a problem hiding this comment.
P2: Revalidate sessions when reinitializing the handle
When autoSelectFamily falls back to another address after an invalid or host/port-mismatched session was supplied, setSession() retains that buffer in this._session but marks _sessionReused false. This path passes the nonempty buffer directly to the new native handle, enabling the shared cache and allowing an unrelated cached session to be resumed despite the earlier validation failure. Please only reapply the session when _sessionReused is true.
|
@innovatedev-john-pope: Let's get this one across the finish line 💪 🏁 Happy to fix-up the review items if you are busy, just let me know. |
|
@innovatedev-john-pope please allow edits from maintainers on your fork so I can push the changes |
|
@bartlomieju sorry, I created this pr as org... Doesn't have that setting apparently. Invited you with write permissions to the fork. Does that work? |
added you as well @tomas-zijdemans, I probably won't get to this for a few days. |
…dated _init session path
…ficates is active rustls only offers a stored session when the new config's verifier Arc is the same instance the session was stored under. build_client_config left the cached-verifier path whenever a process-level custom CA was set, so every connection after tls.setDefaultCACertificates() built a fresh verifier and opt-in resumption never succeeded. Keep such connections on the cached path; op_set_default_ca_certificates already drops the caches whenever the CA list changes.
|
Thanks @innovatedev-john-pope. Updated it last night, ready for review @bartlomieju 🙏 |
bartlomieju
left a comment
There was a problem hiding this comment.
Thanks for driving this @innovatedev-john-pope and @tomas-zijdemans — and thanks for the upfront AI-assistance disclosure, that's appreciated.
I'm holding off the next patch release until this lands, so let's get the remaining items closed out quickly.
Overview
The approach is sound. Wrapping the process-global ClientSessionStore so that retrieval (tls12_session / take_tls13_ticket) is gated per-connection while insertion stays ungated is the right shape — it restores Node's "resumption is opt-in per connection" semantics without breaking getSession() or the 'session' event.
I checked the lifetime question: the Arc<AtomicBool> is cloned into the config back in init_client_tls, but rustls only reads the store when ClientConnection::new runs in start() (tls_wrap.rs:2420), so a setSession() after handle creation still takes effect. That's correct.
I also verified the security property the setSession() comment worries about: onConnectSecure re-runs checkServerIdentity() on resumed sessions, so a forged session can't skip the hostname check even if the gate were bypassed. Good.
Asks
Left inline, summarized:
- Blocking-ish: commit
e5ba4bf(theis_default_path/setDefaultCACertificateschange) is a second distinct fix that's neither described in the PR body nor tested. It's correct as far as I can tell, but please document it and add a test. _initno longer applyingoptions.sessionregresses directly-constructednew tls.TLSSocket(sock, { session }). Acceptable, but say so in the comment.- Test gaps: cross-host session rejection, and the new
kReinitializeHandlere-apply path. - Two nits:
setSession()ordering intls.connect(), and the empty-buffer sentinel in the Rustset_session.
CI
pre-build is still pending and there are no build/test results yet — I'll want a green run before merging regardless of the above.
| // by `setDefaultCACertificates` stays on this path: it is shared by all | ||
| // default-CA connections, and `op_set_default_ca_certificates` drops the | ||
| // cached verifiers whenever it changes. | ||
| let is_default_path = !has_explicit_ca |
There was a problem hiding this comment.
This is a second, distinct fix bundled into the PR (commit e5ba4bf) and it isn't mentioned in the description. I traced it and I agree it's correct — op_set_default_ca_certificates clears both cached_default_verifier and cached_insecure_verifier on every change (ext/node/ops/tls.rs:236-238), so the verifier Arc identity that rustls keys compatible_config on stays consistent, and without this change opt-in resumption would silently never work after tls.setDefaultCACertificates().
Two asks:
- Please describe it in the PR body — it's a behavior change reviewers shouldn't have to reverse-engineer from the diff.
- It has no test. Please add one:
setDefaultCACertificates(), then twotls.connect()s where the second passesoptions.session, and assertisSessionReused() === true. That's exactly the regression this line guards.
| /// This enables resumption on the underlying `NodeClientSessionStoreWrapper`. | ||
| #[fast] | ||
| fn set_session(&self, #[buffer] _session: &[u8]) {} | ||
| fn set_session(&self, #[buffer] session: &[u8]) { |
There was a problem hiding this comment.
nit: inferring intent from !session.is_empty(), with JS passing new Uint8Array(0) to mean "disable", is indirect. A dedicated set_session_allowed(bool) fast op would say what it means and drop the sentinel buffer on the JS side. Cosmetic — not blocking.
| if (options.session) { | ||
| ssl.setSession(options.session); | ||
| } | ||
| // options.session is not applied here: it can't be validated against |
There was a problem hiding this comment.
Worth spelling out the user-visible consequence here: Node's _init does if (options.session) ssl.setSession(options.session), so after this change new tls.TLSSocket(sock, { session }) constructed directly (not through tls.connect()) will never resume — kConnectOptions is never set, so syntheticSessionMatches() always returns false.
I think that's an acceptable trade given the synthetic-session design, but the comment should state it so the next person doesn't treat it as a bug.
Related: session: options.session in the options object passed to new TLSSocket() in tls.connect() (~line 1699) is now dead. Drop it or annotate it.
| this._session, | ||
| this[kConnectOptions], | ||
| ); | ||
| if (this._handle) { |
There was a problem hiding this comment.
The gating logic itself is right. One ordering note that isn't in the diff but is load-bearing for it: in tls.connect() (~line 1726) tlssock.setSession(options.session) runs after tlssock.connect(options, tlssock._start). It works today only because connect() defers _start to the 'connect' event, and rustls reads the store lazily in ClientConnection::new inside start() (tls_wrap.rs:2420).
tlssock[kConnectOptions] = options is already assigned above the connect() call, so moving the if (options.session) block up above tlssock.connect(...) removes the hazard for free. Cheap robustness win.
| // Only re-apply a session that already passed setSession()'s | ||
| // host:port validation; an invalid buffer must not enable | ||
| // resumption on the new handle. | ||
| if (this._session && this._sessionReused) { |
There was a problem hiding this comment.
This re-apply path (Happy Eyeballs / autoSelectFamily fallback re-creating the TLSWrap) is new code with no test coverage. Please add one — connect with autoSelectFamily: true to a host where the first address fails, pass options.session, and assert resumption still happens on the fallback handle.
| const res4 = await deadline(p4, 5000); | ||
| assertEquals(res4.isReused, true); | ||
|
|
||
| // Connection 5: Passing invalid/unmatched buffer must NOT resume |
There was a problem hiding this comment.
Good that the actual regression (connection 2 must not resume) is covered.
Connection 5 only feeds garbage bytes, which any check would reject. The property setSession()'s comment actually claims is stronger: a well-formed synthetic session issued for a different host:port must not enable resumption. Please add that case — take the session from a connection to server A and pass it to a connection to server B, assert isSessionReused() === false. That's the one that would catch a regression in syntheticSessionMatches().
nit: this is one ~130-line test covering five distinct scenarios. Splitting it with t.step() per connection would make a failure point at the case that broke instead of a bare assertEquals(false, true).
- Replace the empty-buffer sentinel with a dedicated setSessionAllowed(bool)
fast op; JS toggles the resumption gate explicitly.
- Apply options.session in tls.connect() before initiating the connection so
the gate no longer depends on _start() being deferred.
- Drop the dead 'session' entry from the TLSSocket options in tls.connect()
and document that directly constructed TLSSocket({ session }) never resumes.
- Split the regression test into per-connection steps; add cross host:port
session rejection, setDefaultCACertificates resumption, and autoSelectFamily
fallback re-apply coverage.
|
Gotha @bartlomieju. See the latest commit. |
|
Suggestion: Closes #36228. ProblemWhen connecting to database servers (e.g. SQL Server via In the TDS protocol, TLS is tunneled through In Node.js, client TLS connections are strictly stateless across connections unless Solution
Verification
Note for reviewers: This PR was developed with AI assistance (Claude / Gemini pair-programming) to investigate the rustls/Node.js compatibility issue and draft the fix. I am not a core Rust/Deno developer, so a thorough review by the team on any idiomatic Rust or internal architectural conventions is warmly welcomed. |
…6592) Closes #36228 Closes #27833 Refs #33693 --------- Co-authored-by: Tomas Zijdemans <[email protected]>
Closes #36228.
Closes #27833.
Refs #33693.
Problem
When connecting to database servers (e.g. SQL Server via
mssql/tedious) or services using connection pools over TLS, the initial connection succeeds, but subsequent connections in the pool fail withETIMEOUTduring the TLS handshake.In the TDS protocol, TLS is tunneled through
PRELOGINpackets, and SQL Server does not support TLS session resumption during TDS prelogin handshakes. Since #33693,ext/nodeattached a shared session cache to every client config unconditionally, causing rustls to offer session resumption on all subsequent connections to the same host even whenoptions.sessionwas not passed.In Node.js, client TLS connections are strictly stateless across connections unless
options.sessionis explicitly passed (orsocket.setSession()is invoked).Solution
NodeClientSessionStoreWrapperinext/node/ops/tls_wrap.rsto gate session resumption offers behind anallow_resumptionflag (defaultfalse), toggled from JS via a dedicatedsetSessionAllowed(bool)fast op.socket.getSession()and'session'events function as expected.kx_hintis un-gated to avoid unnecessaryHelloRetryRequestround trips on full handshakes.options.sessionis passed orsocket.setSession()is called,allow_resumptionis enabled only ifsyntheticSessionMatchesvalidates that the ticket matches the destination host/port.tls.connect()applies the session before initiating the connection, so the gate is set before the handshake can start.kReinitializeHandle(Happy Eyeballs /autoSelectFamilyfallback), resumption is re-enabled on the re-created handle only when the session previously passed validation.tls.setDefaultCACertificates(). rustls only offers a stored session when the config's verifierArcis the same instance the session was stored under, andbuild_client_configleft the cached-verifier path whenever a process-level custom CA was set, building a fresh verifier per connection. Connections whose only non-default input is the process-level custom CA now stay on the cached path;op_set_default_ca_certificatesalready invalidates the cached verifiers whenever the CA list changes._init): a directly constructednew tls.TLSSocket(socket, { session })(not viatls.connect()) does not resume — there are no connect options to validate the synthetic session against.tests/unit_node/tls_test.ts: per-connection opt-in/opt-out steps (including a well-formed session for a different host:port), resumption aftersetDefaultCACertificates(), and session preservation across theautoSelectFamilyfallback handle re-creation.Verification
cargo test --test unit_node tlsandcargo test --test node_compat test-tls-client-resume.test-tls-client-resume-12,test-https-client-resume,test-tls-session-cache,test-tls-secure-session, and the unit_node http/https/http2 suites.Note for reviewers: This PR was developed with AI assistance (Claude / Gemini pair-programming) to investigate the rustls/Node.js compatibility issue and draft the fix. I am not a core Rust/Deno developer, so a thorough review by the team on any idiomatic Rust or internal architectural conventions is warmly welcomed.