Skip to content

fix(ext/node): do not resume client TLS sessions unless requested - #36592

Merged
bartlomieju merged 6 commits into
denoland:mainfrom
innovatedev-agents:fix/node-tls-client-session-resumption
Aug 26, 2026
Merged

bartlomieju merged 6 commits into
denoland:mainfrom
innovatedev-agents:fix/node-tls-client-session-resumption

Conversation

@innovatedev-john-pope

@innovatedev-john-pope innovatedev-john-pope commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

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 with ETIMEOUT during the TLS handshake.

In the TDS protocol, TLS is tunneled through PRELOGIN packets, and SQL Server does not support TLS session resumption during TDS prelogin handshakes. Since #33693, ext/node attached a shared session cache to every client config unconditionally, causing rustls to offer session resumption on all subsequent connections to the same host even when options.session was not passed.

In Node.js, client TLS connections are strictly stateless across connections unless options.session is explicitly passed (or socket.setSession() is invoked).

Solution

  • Implemented NodeClientSessionStoreWrapper in ext/node/ops/tls_wrap.rs to gate session resumption offers behind an allow_resumption flag (default false), toggled from JS via a dedicated setSessionAllowed(bool) fast op.
  • Newly issued session tickets continue to be recorded into the store so socket.getSession() and 'session' events function as expected.
  • kx_hint is un-gated to avoid unnecessary HelloRetryRequest round trips on full handshakes.
  • When options.session is passed or socket.setSession() is called, allow_resumption is enabled only if syntheticSessionMatches validates 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.
  • On kReinitializeHandle (Happy Eyeballs / autoSelectFamily fallback), resumption is re-enabled on the re-created handle only when the session previously passed validation.
  • Second fix (uncovered by the regression test): opt-in resumption silently never worked after tls.setDefaultCACertificates(). rustls only offers a stored session when the config's verifier Arc is the same instance the session was stored under, and build_client_config left 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_certificates already invalidates the cached verifiers whenever the CA list changes.
  • Known limitation (documented in _init): a directly constructed new tls.TLSSocket(socket, { session }) (not via tls.connect()) does not resume — there are no connect options to validate the synthetic session against.
  • Tests in tests/unit_node/tls_test.ts: per-connection opt-in/opt-out steps (including a well-formed session for a different host:port), resumption after setDefaultCACertificates(), and session preservation across the autoSelectFamily fallback handle re-creation.

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.

…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-cla-assistant

deno-cla-assistant Bot commented Aug 15, 2026 •

Copy link
Copy Markdown

Deno Individual Contributor License Agreement

All contributors have signed the CLA. Thank you!

Re-run CLA check


This is an automated message from CLA Assistant

@tomas-zijdemans tomas-zijdemans left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread ext/node/polyfills/_tls_wrap.js Outdated
this._handle.setAlpnProtocols(options.ALPNProtocols);
}
if (this._session) {
this._handle.setSession(this._session);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
}

Comment thread ext/node/polyfills/_tls_wrap.js Outdated

if (options.session) {
ssl.setSession(options.session);
if (syntheticSessionMatches(options.session, options)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ext/node/polyfills/_tls_wrap.js Outdated
this._handle.setAlpnProtocols(options.ALPNProtocols);
}
if (this._session) {
this._handle.setSession(this._session);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

@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 innovatedev-john-pope changed the title fix(ext/node): do not resume client TLS sessions unless requested (#36228) fix(ext/node): do not resume client TLS sessions unless requested Aug 25, 2026
@bartlomieju

Copy link
Copy Markdown
Member

@innovatedev-john-pope please allow edits from maintainers on your fork so I can push the changes

@innovatedev-john-pope

Copy link
Copy Markdown
Contributor Author

@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?

@innovatedev-john-pope

Copy link
Copy Markdown
Contributor Author

@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.

added you as well @tomas-zijdemans, I probably won't get to this for a few days.

…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.
@tomas-zijdemans

Copy link
Copy Markdown
Contributor

Thanks @innovatedev-john-pope. Updated it last night, ready for review @bartlomieju 🙏

@bartlomieju bartlomieju left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Blocking-ish: commit e5ba4bf (the is_default_path / setDefaultCACertificates change) 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.
  2. _init no longer applying options.session regresses directly-constructed new tls.TLSSocket(sock, { session }). Acceptable, but say so in the comment.
  3. Test gaps: cross-host session rejection, and the new kReinitializeHandle re-apply path.
  4. Two nits: setSession() ordering in tls.connect(), and the empty-buffer sentinel in the Rust set_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.

Comment thread ext/node/ops/tls_wrap.rs
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. Please describe it in the PR body — it's a behavior change reviewers shouldn't have to reverse-engineer from the diff.
  2. It has no test. Please add one: setDefaultCACertificates(), then two tls.connect()s where the second passes options.session, and assert isSessionReused() === true. That's exactly the regression this line guards.

Comment thread ext/node/ops/tls_wrap.rs Outdated
/// This enables resumption on the underlying `NodeClientSessionStoreWrapper`.
#[fast]
fn set_session(&self, #[buffer] _session: &[u8]) {}
fn set_session(&self, #[buffer] session: &[u8]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread ext/node/polyfills/_tls_wrap.js Outdated
if (options.session) {
ssl.setSession(options.session);
}
// options.session is not applied here: it can't be validated against

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread tests/unit_node/tls_test.ts Outdated
const res4 = await deadline(p4, 5000);
assertEquals(res4.isReused, true);

// Connection 5: Passing invalid/unmatched buffer must NOT resume

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@tomas-zijdemans

tomas-zijdemans commented Aug 26, 2026 •

Copy link
Copy Markdown
Contributor

Gotha @bartlomieju. See the latest commit.
@innovatedev-john-pope : I do not have access to change the PR description, so need your help on that

@tomas-zijdemans

Copy link
Copy Markdown
Contributor

Suggestion:

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 with ETIMEOUT during the TLS handshake.

In the TDS protocol, TLS is tunneled through PRELOGIN packets, and SQL Server does not support TLS session resumption during TDS prelogin handshakes. Since #33693, ext/node attached a shared session cache to every client config unconditionally, causing rustls to offer session resumption on all subsequent connections to the same host even when options.session was not passed.

In Node.js, client TLS connections are strictly stateless across connections unless options.session is explicitly passed (or socket.setSession() is invoked).

Solution

  • Implemented NodeClientSessionStoreWrapper in ext/node/ops/tls_wrap.rs to gate session resumption offers behind an allow_resumption flag (default false), toggled from JS via a dedicated setSessionAllowed(bool) fast op.
  • Newly issued session tickets continue to be recorded into the store so socket.getSession() and 'session' events function as expected.
  • kx_hint is un-gated to avoid unnecessary HelloRetryRequest round trips on full handshakes.
  • When options.session is passed or socket.setSession() is called, allow_resumption is enabled only if syntheticSessionMatches validates 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.
  • On kReinitializeHandle (Happy Eyeballs / autoSelectFamily fallback), resumption is re-enabled on the re-created handle only when the session previously passed validation.
  • Second fix (uncovered by the regression test): opt-in resumption silently never worked after tls.setDefaultCACertificates(). rustls only offers a stored session when the config's verifier Arc is the same instance the session was stored under, and build_client_config left 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_certificates already invalidates the cached verifiers whenever the CA list changes.
  • Known limitation (documented in _init): a directly constructed new tls.TLSSocket(socket, { session }) (not via tls.connect()) does not resume — there are no connect options to validate the synthetic session against.
  • Tests in tests/unit_node/tls_test.ts: per-connection opt-in/opt-out steps (including a well-formed session for a different host:port), resumption after setDefaultCACertificates(), and session preservation across the autoSelectFamily fallback handle re-creation.

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.

bartlomieju

This comment was marked as off-topic.

@bartlomieju
bartlomieju merged commit 1f837b6 into denoland:main Aug 26, 2026
136 checks passed
bartlomieju pushed a commit that referenced this pull request Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants