Skip to content

Bun.connect: a tls object that sets no option means tls: true - #44681

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/0037769d/tls-object-means-tls
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/0037769d/tls-object-means-tls

Conversation

@robobun

@robobun robobun commented Oct 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Behavior change, no flag: on Bun.connect, a tls object that sets no option now gets the tls: true config (tls_true_defaults, rejectUnauthorized on). The other answers are a throw on the same line, or no change.
  • tls absent, undefined, null or false keeps its meaning. Bun.listen does not change.
  • Verified: test/js/bun/net/socket.test.ts, 41 new cases, 29 fail without the fix. Also ran 49 more socket and TLS test files.
  • Self-reviewed: 12 concerns raised, 11 addressed, 1 in part (Notes).

Background

Downsides

  • A caller that wants a plain socket from tls: {} breaks with no error: data never runs. BunSmtp then hits its own 5-minute timer.
  • A release with TLS cannot change to a throw without a second break. A throw can become TLS later.
  • Cost for other callers: 0 allocations, 0 syscalls, +1 branch for a configured object. .text grows 256 bytes.
Notes

The question. The body of #44618 says: "One open question: tls: {} (an object that names no TLS option) is plain TCP on Bun.listen / Bun.connect, here and on main. It is the same trap as tls: [], but changing it changes a Bun default, so it is left alone." This PR is one tested answer for the Bun.connect half. There is no urgency. The three answers:

  1. TLS with the tls: true defaults (this PR). Callers that meant TLS work with no release of their own. A caller that meant plaintext stalls with no event.
  2. A throw on the same arm, as tls: consolidate the open TLS fixes (both engines, node:tls, node:https, WebSocket, SQL) #44618 does for tls: []. It fails closed for each shape and tells the author at once. It can become TLS in a later minor, because an error turns into a working call. socket.upgradeTLS and RedisClient already throw for the same object.
  3. No change until a release that lists breaking changes.

If the answer is 2, I rework the same arm. If it is 3, I close this.

Shapes that move. Each of these dialed plaintext on Bun.connect and now sends a ClientHello: {}, { rejectUnauthorized: undefined }, { requestCert: false }, { ca: [] }, { ca: undefined, cert: undefined, key: undefined }, { ecdhCurve: "auto" }, { lowMemoryMode: false }, { secureOptions: 0 }, { checkServerIdentity() {} }, { unknownKey: 1 }, Object.create(null). The first byte on the wire goes from 83 (S of the application's SECRET) to 22 (TLS handshake record), in the hostname, unix and fd forms.

What a caller that meant plaintext sees. The handshake runs against its plain peer. With no handshake handler and a peer that speaks first: the promise resolves, open runs with authorized === false and getAuthorizationError().code === "EPROTO", then close runs. data, error and end do not run. With a peer that waits, no handler runs. This is what tls: true does today. devmoamal/BunSmtp (src/relay.ts:83) dials an SMTP relay with tls: {}, reads the plaintext 220 greeting in data, and upgrades with STARTTLS. It registers data, error and end only, so after this change its own 5-minute timer is the one signal.

Why Bun.listen is not in this PR. The first version changed both modes. slidesk 2.19.0 starts its telnet server with tls: { key, cert, passphrase }, and each member is undefined when no certificate is configured. On main that is a plain listener. With the change it is a TLS listener with no certificate: listen() does not throw and the client gets nothing. No caller was found that needs a listener to refuse. A client can do TLS with no option, and a server cannot. So the arm has the guard !mode.is_server(). Bun.serve({ tls: {} }) also stays HTTP. The client arm alone stops the cleartext when both ends pass tls: {}: the listener receives a ClientHello.

Other effects of the arm.

  • With no handshake handler, open runs after the handshake and not at TCP connect, as for each TLS socket.
  • Bun.connect({ tls: { secureContext } }) alone now reaches the code that adopts the context (Listener.rs:1184-1200).
  • upgradeTLS on a socket from Bun.connect({ tls: {} }) returns undefined, because the socket is already TLS.

Not changed, same parser result.

  • Bun.listen: a tls object that sets no option is a listener without TLS.
  • socket.upgradeTLS({ tls: {} }) and upgradeDuplexToTLS throw Expected "tls" option. test/js/bun/net/socket.test.ts:756-761 pins the throw.
  • new Bun.RedisClient(url, { tls: {} }) throws Expected tls to be a object for 'tls'.
  • Bun.serve({ tls: {} }) and tls: [{}] give an http: server. Bun.serve also parses its top-level options object with the same parser (ServerConfig.rs:1271), so the parser's None must stay.
  • { requestCert: false } is not a reason for this PR. It stopped counting as a TLS option in 1.3.0 at each entry point, and that needs its own change in the parser.

Why this line. SocketConfig::from_js has two callers, Listener::listen (SocketMode::Server) and Listener::connect_inner (SocketMode::Client). This match is the only reader of the object arm of the tls union. Later frames see only ssl: Option<SSLConfig>. node:net and node:tls pass through the same line and never send such an object: the client sets requestCert: true.

Bun.SQL. src/js/internal/sql/shared.ts:2168-2174: "Explicit tls/ssl options request an encrypted connection: if the server declines TLS, the connection is aborted instead of continuing in plaintext." test/js/sql/adapter-env-var-precedence.test.ts:422-426 pins the literal tls: {} to sslMode 2. The rule is for a client only.

Callers found.

  • npm, weekly downloads for 2026-09-28 to 2026-10-04 in parentheses: bunqueue 2.9.11 (7,597), @fuman/bun 0.0.21 (4,961), bun-routeros 1.1.0 (16), irc-client-ts 0.23.1 (14), @ninots/mail 0.1.2 (10), bun-kafka 0.1.0 (10). Each builds the object from optional settings when its user asks for TLS, for example tls: options.tls ? {} : undefined. I read the six call sites.
  • They come from a survey of 142 npm packages that call Bun.connect or Bun.listen. I did not check the other 136 classifications again. slidesk was not in it, so the survey is not complete.
  • Needs the plain result: devmoamal/BunSmtp for Bun.connect, slidesk for Bun.listen. I read both sources.

Measurements. Release builds of main 3f1765a with and without the patch, linux x64.

  • SocketConfig::from_js own instructions, second call, gdb stepi with calls stepped over (base → PR): tls absent 147 → 146, false 151 → 149, true 168 → 164, { serverName } 175 → 175, { requestCert: true, rejectUnauthorized: true } 175 → 175. Conditional branches for a configured object 16 → 17. memcpy calls 4/4/5/5/5 on both.
  • Other spellings of the arm cost more. match ..? { None => .., configured => configured } adds one 171-byte memcpy and 11 instructions to a configured object. A check after the outer match adds 13 instructions to each call. So the arm is one match on the parser's Result.
  • mimalloc calls on the JS thread for one dial (base → PR): plain 159 → 159, tls: true 377 → 377, { serverName } 405 → 405. tls: {} with the patch: 401.
  • Syscalls for 100 plain dials: socket 100, connect 100, sendto 100, recvfrom 200, setsockopt 200, epoll_ctl 300, epoll_pwait2 200, close 101 on both builds. 100 tls: {} dials with the patch make the same calls as 100 tls: true dials.
  • Option property reads: plain Bun.connect 14 (1 of tls) and plain Bun.listen 12 (1 of tls) on both builds. tls: {} on Bun.connect goes from 14/1 to 15/2, the same as tls: true.
  • SSL_CTX_new calls for 1 tls: true dial and 1,000 tls: {} dials in one process: 1.
  • .text 58,536,037 → 58,536,293 bytes. SocketConfig::from_js 2,270 → 2,322 bytes. The stripped binary has the same size. No host function and no codegen entry is added.

Tests. 41 new cases pass on the debug build of this branch. On a release build of main 3f1765a, 29 fail (26 by assertion, the 3 untrusted-certificate cases by timeout) and the 12 controls pass. The cases:

  • the first chunk a raw peer gets, for six objects and four controls, over tcp, unix and fd (30)
  • a Bun.listen and a Bun.connect that both pass tls: {} (1)
  • the events against a plaintext peer, which must equal those of tls: true (6)
  • an untrusted certificate is rejected (3)
  • a trusted certificate is accepted with no handshake handler, in a child process with NODE_EXTRA_CA_CERTS (1)

On Windows the unix transport is a named pipe, and one row is added to the Windows-only table that counts the SSL_CTX of a named-pipe connect. I could not run Windows. I read the named-pipe arms (Listener.rs:1202-1442) and did not run them.

The 50 files are test/js/bun/net (10), test/js/node/tls (28), test/js/node/net (10), test/regression/issue/21792.test.ts and test/js/bun/http/bun-connect-x509.test.ts. On the debug build, 19 other tests in them fail on my machine, and this change causes none. Eight need the public internet. Ten run past a timeout: eight of those also time out on a debug build of main, and two pass when run alone. One TLS reset test in node-tls-server.test.ts fails in a whole-file run on main too.

Self-review. The review asked for these and they are done: the listener half is out, the body says that no issue exists and names the callers that lose, the requestCert regression is out of the reasons, the Bun.SQL rule is cited, the landing order is stated, a test shows an accepted handshake with no handshake handler, a test pins what a plaintext peer gives, and the Windows named-pipe arm has a case. In part: the review also ran each of the six packages against a TLS-only port. I did not repeat that run, so its numbers are not here.

#44618. git merge-tree against its head 1c0cb80 gives one conflict hunk, in src/runtime/socket/Handlers.rs. The resolution passes that branch's mode.is_server() to from_generated and tls_true_defaults.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/net/socket.test.ts

SSLConfig::from_generated returns None when no member of the options
object differs from its default. SocketConfig::from_generated stored
that None. So Bun.connect({ tls: {} }) opened a socket without TLS and
sent the first write in cleartext.

On a client, the object arm now stores the tls: true defaults when the
parser returns None. A tls option that is absent, undefined, null or
false keeps its meaning. Bun.listen does not change.
@robobun

robobun commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. It waits for a maintainer's choice between the three answers in the description (TLS, a throw, or no change).

How I reproduced it: a plain net.createServer records the first chunk it gets from this client:

Bun.connect({ hostname: "127.0.0.1", port, tls: {}, socket: { open(s) { s.write("SECRET"); }, data() {} } });
  • main 3f1765a, release and debug builds: the chunk is SECRET (first byte 83).
  • this branch: the chunk is a ClientHello (first byte 22), the same as for tls: true.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants