Skip to content

tls.connect() ignores the session option when socket is provided (regression v22.22.3 → v22.23.1) #64402

Description

@RyanEwen

Version

v22.23.1

Platform

Linux (docker node:22.23.1-bookworm-slim, x86_64); also reproduced on the non-slim image

Subsystem

tls

What steps will reproduce the bug?

Run the self-contained script below (starts a local TLS 1.2 server, then connects twice offering the first connection's session):

// Repro: tls.connect() ignores the `session` option when `socket` is provided.
// Resuming a TLS 1.2 session works with a direct tls.connect({host, port,
// session}) but silently does a full handshake when upgrading an existing
// net.Socket via tls.connect({socket, session}). Regressed between v22.22.3
// (wrapped path resumes) and v22.23.1 (wrapped path ignores the session).
//
// Real-world impact: FTPS servers with vsftpd require_ssl_reuse (e.g. Bambu
// Lab H2D 3D printers) reject every data connection from clients like
// basic-ftp, which upgrade the already-connected data socket and pass
// `session` exactly this way.
'use strict'
const tls = require('node:tls')
const net = require('node:net')

// Throwaway self-signed localhost cert so the repro is a single file.
const key = `-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDsN1UvQ0kux/m8
snGI8rPBL5kXYXe2E1IV/V8ImQDbFouay2bZIL4vPdhVsNGH7+oprUtQ9sgpKNue
GN96aGcIsUSZN6HcvFZhS78dk6FTBDaSVbUFijp59WS1WdLmmkFYva7SLGJ5VE9A
2tB4DSPcw0ofqESV2YMhHfsc4RhXJ6FcumWPv8yosT3lJAdZjxoBuxGtysY89bff
6ZNBIFnS8tb1W+L2qAk3p7GEIklJUDemmt1HDwu4vA/820Y0UB+KFNLNT4S/wUKw
J7nWJfLQ1A9BiQiRsJgHqRykXYFB9BO0uuQw+SxXtMEqU5CtlnB8Jf2LOfLbhjBx
acwD7oqfAgMBAAECggEAE+l8LE5DOobP9giiynUPEw9km9RzB22sgz8HBk4DhPRr
E0LnKhF5BrlzZZHQr+FY/2dkzG/pIpMXpEWbfRGU8eYjjrjiU52quGvusdsSg9F8
mixQZKWo1UQK18C5JwhEzuq6dGwaZvjk72YioaQV7FOoCXAhS/J4e8+vrdkJuLrs
iT5VvaB+Lpw6Se3OszyWQ4ceFs9WZPLljlxTFFXAADVvgIdMryygotag7zHvbXes
q7Gw/XaIm/RME1I3b7wxYQLeHn1lVQwb5ZjaSZB/2UEa6/s9PCNuerxJMg52cXKA
BDX6txAOyNoQBXU2aHmw7hGWfE2zYvSR9LO4j0RyMQKBgQD2wSwqd/CvYjEAS49e
hjPYr0jbrXVPHxUZeJvliQq3J4ocH+ARDUankVVbJmgxa4WccmDbLGFQil2UXHad
PMTAwysL1Fgvq9NabPJjqsBHLw0UPupDysI99c0K9EvfgjsUVBvtHhx1OSPJRtoa
hLhQpoAO0ASMn7iE7NezuzcW0QKBgQD1ERPNx+7RsQ/NuvC6qgI85DAaVxtqi6oN
6A/ddsz7wh5A0y0Io6Yca7ax8LJ86cPtUqeRfkKXjAZMYN887gQIIwAnJ+I7r8sb
96QXnZwNpoGIhpApbl6UW0WESqAZ6QNrdBVjUdjyHPmD2sxYWtzmmAQ08KD9YmH1
gjjtKobGbwKBgFJel5SxpwmUuJDFqF1AZUw+7w5N7+vyj0OKbFgKECalr0fGKDDp
Ap3rONgNkRh2MQrRb52aSf3twmFFIF9Kqs9CFzuCrdF0BUCZP6yfkkHw5efNPLxW
kdLHG6Q9eppoybn2fcAAnjVPVq/Y0/OoPDLH8dWAARTEOH8+5J5dr30RAoGBAIrR
9anny3y1FAKyFpNmooXjrOv+0+Ty4I2oGvSUy2EBgSmvdh8itIH88iyx8CmaMgZJ
f+qG3yoBg7/YUByvp1EEop2LljfBSQi6qxVFjpSc8Vto/li50JGxchuSOkbzG6DO
zR7Jyl670wvB8WdLcT6ediOW+1sNU/hCoASYxme5AoGAGTx0YXAEPlyzvv3+AKXW
e3XdzG9K+JMnFVgXF0KaMfzsS8zdQsfH2hRENiFBwPlzd0GH/CnbXZZjYtf1zvBR
m73fmbSH3K2CF2pf1+D8P+j9NCyPvBfINX+2EBsTjoDj8NswMiqO+yyFLgIIm/Yh
SwMI12dwWQ9jqc1PN6/pxkM=
-----END PRIVATE KEY-----`
const cert = `-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUTSO0VACyi1UT4Hr3aytSSsWVuwAwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcxMDA1MTUxNVoXDTM2MDcw
NzA1MTUxNVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEA7DdVL0NJLsf5vLJxiPKzwS+ZF2F3thNSFf1fCJkA2xaL
mstm2SC+Lz3YVbDRh+/qKa1LUPbIKSjbnhjfemhnCLFEmTeh3LxWYUu/HZOhUwQ2
klW1BYo6efVktVnS5ppBWL2u0ixieVRPQNrQeA0j3MNKH6hEldmDIR37HOEYVyeh
XLplj7/MqLE95SQHWY8aAbsRrcrGPPW33+mTQSBZ0vLW9Vvi9qgJN6exhCJJSVA3
pprdRw8LuLwP/NtGNFAfihTSzU+Ev8FCsCe51iXy0NQPQYkIkbCYB6kcpF2BQfQT
tLrkMPksV7TBKlOQrZZwfCX9izny24YwcWnMA+6KnwIDAQABo1MwUTAdBgNVHQ4E
FgQUCn1orlDMdSN/3DcjQ0vIJGEamMswHwYDVR0jBBgwFoAUCn1orlDMdSN/3Dcj
Q0vIJGEamMswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAp+a/
cjmmEP0kbSNX8DMQfhhOV+sJhIQaeRP/uFlUmOwJq/BjoZyWUqfU94j6/VLM3ssz
6vYduhgTmQsy3oC/a73YHB5SwmwiT2CbQQ99zyoj65ZXIAFgsrPEEe3g4ufSuP5t
IFtKM6+QspM4VBWlXiUpI/AzPr7L2d9sJGoKTDSgnL+0YQx4PDeAixXtABKa6C+r
+olQEjaqnucctM3Rvr25co2scxbm7broFGLiFF26JyRKZjoatROB26Yp19QGdQHl
L5+7VMHzOa4fc5plpfZ+pEcGDdWA0jGhBTnwami9N9FpMW2/D/2PYh2TGj7nJ5vx
KfOP+J+ltK+bfdET5Q==
-----END CERTIFICATE-----`

const server = tls.createServer({ key, cert, maxVersion: 'TLSv1.2' })
server.listen(0, '127.0.0.1', async () => {
  const port = server.address().port
  const connectFresh = (session) => new Promise((resolve, reject) => {
    const s = tls.connect({ host: '127.0.0.1', port, rejectUnauthorized: false, maxVersion: 'TLSv1.2', session }, () => resolve(s))
    s.once('error', reject)
  })
  const connectWrapped = (session) => new Promise((resolve, reject) => {
    const plain = net.connect({ host: '127.0.0.1', port }, () => {
      const s = tls.connect({ socket: plain, rejectUnauthorized: false, maxVersion: 'TLSv1.2', session }, () => resolve(s))
      s.once('error', reject)
    })
    plain.once('error', reject)
  })

  const first = await connectFresh()
  const session = first.getSession()
  first.destroy()

  const fresh = await connectFresh(session)
  console.log(`fresh   tls.connect({host, port, session}): isSessionReused = ${fresh.isSessionReused()}`)
  fresh.destroy()

  const wrapped = await connectWrapped(session)
  console.log(`wrapped tls.connect({socket, session}):     isSessionReused = ${wrapped.isSessionReused()}`)
  const ok = wrapped.isSessionReused()
  wrapped.destroy()

  console.log(ok ? 'OK' : 'BUG: session option ignored on the wrapped-socket path')
  server.close()
  process.exitCode = ok ? 0 : 1
})

How often does it reproduce? Is there a required condition?

Always on v22.23.1. The required condition is passing an already-connected net.Socket via the socket option together with session.

What is the expected behavior? Why is that the expected behavior?

Both connections should resume the offered session (isSessionReused() === true), as documented for the session option and as v22.22.3 behaves:

fresh   tls.connect({host, port, session}): isSessionReused = true
wrapped tls.connect({socket, session}):     isSessionReused = true
OK

What do you see instead?

On v22.23.1 the wrapped-socket path silently performs a full handshake — the session option is accepted but has no effect:

fresh   tls.connect({host, port, session}): isSessionReused = true
wrapped tls.connect({socket, session}):     isSessionReused = false
BUG: session option ignored on the wrapped-socket path

Bisected across release lines: v22.22.3 resumes on both paths; v22.23.1 only on the fresh path.

Additional information

Real-world impact: FTPS servers running vsftpd with require_ssl_reuse (notably Bambu Lab H2-series 3D printers) require every data connection to resume the control connection's TLS session, and reject the transfer otherwise (522 SSL connection failed: session reuse required). FTP clients such as basic-ftp implement this by upgrading the already-connected passive data socket with tls.connect({ socket, session: controlSocket.getSession() }) — exactly the path this regression breaks — so on v22.23.x every such transfer fails while the same code works on earlier releases. (Working around it in our application by using a fresh tls.connect for the data connection.)

Metadata

Metadata

Assignees

No one assigned

    Labels

    netIssues and PRs related to the net subsystem.tlsIssues and PRs related to the tls subsystem.

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions