Repository navigation
tls: consolidate the open TLS fixes (both engines, node:tls, node:https, WebSocket, SQL) - #44618
Merged
Merged
Conversation
This was referenced Oct 6, 2026
Closed
Closed
Closed
Closed
robobun
added a commit
that referenced
this pull request
Oct 20, 2026
### Problem - `setKeyCert(ctx)` in an `ALPNCallback` does not change the served certificate. On TLS 1.2 a session of one ALPN protocol resumes under another, with `authorized: true` for a client that only the other context trusts. - Cause: BoringSSL runs the ALPN selection callback (`select_alpn_callback`, `socket_body.rs`) after it picked the certificate and, on TLS 1.2, the session (`handshake_server.cc:781`, `:803`, `:881`). ### Fix - usockets copies the ClientHello's ALPN list and hands it over from BoringSSL's certificate callback, before those picks. `ALPNCallback` runs there. - A server-side `setKeyCert()` uses the SNI switch, `us_ssl_apply_selected_ctx`. That switch does nothing once BoringSSL has begun to pick. - Verified: `node-tls-context.test.ts` (77 new tests, 57 fail on main), Node's `test-tls-server-setkeycert.js` (added, fails on main). - Self-reviewed: 37 concerns raised, 30 addressed. The notes list the rest. ### Background - Session id context: bytes BoringSSL copies into each new session. A session resumes only under the same id (#42998). - Certificate callback (`SSL_set_cert_cb`): runs once per server handshake, after version and SNI, before cipher, certificate and session. - Considered recomputing the verdict after a late switch: a second verdict path, wrong certificate still served. Considered Node's in-place identity: it copies BoringSSL's credential and chain logic. ### Downsides - `setKeyCert(ctx)` in `ALPNCallback` now applies the `ciphers` and `sigalgs` of `ctx`, as SNI contexts already do. Node keeps the server's. - On TLS 1.2, `getCipher()`, `getSession()`, `isSessionReused()` and `getPeerCertificate()` report nothing inside `ALPNCallback`. On TLS 1.3 `setKeyCert()` beats an `SNICallback` context. Node does the reverse. - One allocation per handshake on `ALPNCallback` servers, about 10 branches on SNI servers. Release text: -544 bytes. <details><summary>Notes</summary> **Outcome table.** The default context trusts ca1 and serves agent2. ALPN `b` selects a context that trusts ca2 and serves agent3. TLS 1.2 and TLS 1.3 give the same result with this PR. | connection | main | this PR | |---|---|---| | full handshake, ALPN `b` | serves agent2 | serves agent3 | | TLS 1.2, ca1 client, session of `a` offered with `b` | resumed, `authorized: true` | full handshake, `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | | TLS 1.2, ca2 client, session of `b` offered with `a` | resumed, `authorized: true` | full handshake, `UNABLE_TO_VERIFY_LEAF_SIGNATURE` | | a session offered with its own protocol | resumed | resumed | | ca2 client, `setImmediate(() => this.setKeyCert(ctxB))` in `ALPNCallback` | `authorized: true`, and the session resumes under the default context with `authorized: true` | `authorized: false` on both connections | | `setKeyCert()` before the ClientHello (in a `'connection'` listener) | `SNICallback` and ALPN are dropped | both run | | `setKeyCert()` in the `SNICallback` of a server-side `TLSSocket` | ALPN is dropped | ALPN is negotiated | **Decisions that need a maintainer.** 1. Ciphers and signature algorithms. A context switch in Bun is `SSL_set_SSL_CTX`, so the `ciphers` and `sigalgs` of the selected context apply. That is already so for `SNICallback` and `addContext()` on main. With this PR it is also so for `setKeyCert()` in `ALPNCallback`, because the switch now comes before BoringSSL picks the cipher. Node applies the identity and the CA store only, and keeps the server's `ciphers` and `sigalgs` on all routes. Probe (server `ciphers: "ECDHE-RSA-AES256-GCM-SHA384"`, TLS 1.2 client that offers only `ECDHE-RSA-AES128-GCM-SHA256`, `setKeyCert({ key, cert })` in `ALPNCallback`): this PR connects with the AES128 suite, Node v26.3.0 and main answer `ERR_SSL_NO_SHARED_CIPHER`. A context that repeats the server's `ciphers` keeps the refusal. The test `setKeyCert() in ALPNCallback applies the ciphers of the selected context` pins this. I did not carry the server's settings across the switch: BoringSSL has no getter for the signing preferences, so it needs the configured strings kept beside each `SSL_CTX`, and it changes the `SNICallback` and `addContext()` routes of main too. 2. TLS 1.3 order. Bun runs `SNICallback` before `ALPNCallback` on both versions. Node runs `ALPNCallback` first on TLS 1.3, because OpenSSL negotiates ALPN before its certificate callback there. So when an `SNICallback` answers a context and `ALPNCallback` calls `setKeyCert()`, Node v26.3.0 serves the `setKeyCert()` context on TLS 1.2 and the `SNICallback` context on TLS 1.3. This PR serves the `setKeyCert()` context on both. 3. A late `setKeyCert()` on a server is a silent no-op. Node does not throw there either. "Late" means after the certificate callback returned. A call that `ALPNCallback` defers with `process.nextTick` or a promise is still in time on an fd socket, because that queue drains before the callback returns, and the switch is then complete (certificate, verdict and session). Node serves the selected certificate for such a call too. On the stream engine the queue drains later and the call changes nothing. **Why the certificate callback and not the select-certificate callback.** The select-certificate callback runs before BoringSSL negotiates the version, so `this.getProtocol()` inside `ALPNCallback` would report `TLSv1.2` for a TLS 1.3 handshake. It also runs before the server name callback that applies `addContext()` entries, which would then replace the context that `ALPNCallback` selected. The certificate callback runs after both. `SSL_set_SSL_CTX` drops it, so `us_ssl_apply_selected_ctx` arms it again while a list waits. **What `ALPNCallback` sees.** Values inside the callback, on the second of two connections of one client (the second resumes): | | main, TLS 1.2 | this PR, TLS 1.2 | Node v26.3.0, TLS 1.2 | |---|---|---|---| | `getCipher()` | the suite | no suite | the suite | | `isSessionReused()` | `true` | `false` | `true` | | `getSession()` | a Buffer | `undefined` | a Buffer | | `getPeerCertificate()` | the peer | `{}` | the peer | On a full TLS 1.2 handshake main reports the suite and a session there, and Node reports neither, like this PR. TLS 1.3 is as on main. `ALPNCallback` also runs for a ClientHello that then fails for lack of a shared cipher (TLS 1.2) or signature algorithm (TLS 1.3). Node runs it after those checks on TLS 1.2. **The ALPN list.** `us_select_cert_alpn` checks the extension with the same CBS calls as `ssl_negotiate_alpn` and keeps the measured length with the copy. A list that BoringSSL is going to reject is not handed over. The copy lives in its own `SSL` ex_data slot, so `us_ssl_rare_t` stays at 64 bytes. **Sizes.** `sizeof(struct us_ssl_rare_t)`: 64 before, 64 after (gdb, debug build). `NewSocket<true>`: 504 bytes before and after (gdb, debug build. The new `alpn_refused` flag sits in padding). Release build, linux-x64, LTO, `size build/release-size/bun`: text 107068349 before, 107067805 after. data and bss are the same. The file is 107243232 bytes in both. **Cost per handshake.** Counted from the code. A server with an `ALPNCallback`: one `malloc` and one `free` for the list copy, and one ex_data slot. A server with an SNI resolver and no `ALPNCallback`: 4 branches in `us_select_cert_cb` to read the per-connection flag, and for each context switch 4 branches in `us_ssl_apply_selected_ctx` plus the null checks inside `SSL_in_init` and `SSL_get_pending_cipher`. No allocation, no system call. A server with neither runs no new code. **Not consolidated.** The two callbacks that BoringSSL reads off the current `SSL_CTX` are still installed where a connection asks for them (`us_ssl_on_alpn_offer`, `us_ssl_set_server_ctx`, `install_sni_alpn_selector`). One install where the `SSL_CTX` is built would remove those sites. It would also run both callbacks on every server handshake, Bun.serve included, because Bun.serve registers its own resolver on contexts from the same builder. **Relation to other work.** #44618 listed this PR as open for a decision. #45471 (server name on the stream TLS engine) rewrites the same arm of `us_select_cert_cb`, and the two conflict in `openssl.c`. This one is the smaller change. Whichever lands second has to keep the ALPN stage behind the name resolution of the stream engine. **Not covered.** - `https.createServer({ ALPNCallback })` does not reach this code: `https.ts` stores the option and nothing reads it. - `setKeyCert()` in the same tick as `new tls.TLSSocket(socket, { isServer: true })` is dropped, because the native handle does not exist yet. The tests wait one `setImmediate`. - On a `listen()` server Bun calls `SNICallback` with the `tls.Server` as `this`, so `this.setKeyCert()` there throws. The tests use the `SNICallback` of a server-side `TLSSocket`. - `setKeyCert()` in `ALPNCallback` is also dropped when the socket already holds the ClientHello at the time it is wrapped (`server.emit("connection", socket)` after a read): `upgradeTLS` feeds the bytes before `net.ts` assigns `_handle`. - The `ciphers` and `sigalgs` of a server do not survive a context switch on any route (decision 1). **Found on the way, same on main, not changed.** Both are in the code that this PR moves from `select_alpn_callback` to `us_dispatch_alpn_offer`. - A Worker that is terminated inside `ALPNCallback` aborts the process: the termination exception reaches `to_utf8()` as the callback's answer. - A protocol name with a byte above 0x7f that `ALPNCallback` selects is refused: `net.ts` decodes the names as latin1 and the answer is encoded as UTF-8. **Self-review, rejected concerns.** - One install of the callbacks at context build (3 concerns): see "Not consolidated". - Apply the identity, the CA store and the session id context to the `SSL` in place, as Node does (1 concern): BoringSSL has no getter for the credential list of a context, and it completes a lone leaf from the store of the current `SSL_CTX`. Both need a copy of its logic. - Fold in the same-tick drop, the `SNICallback` receiver and `https.Server` (3 concerns): other mechanisms. They are listed above. A second pass over the reworked diff (the guard, the lifetime of the list copy, the parser, the new tests) found no defect in the product code and two in the tests. Both are fixed: the raw ClientHello rows now wait for the server's first record, and one test has the name of what it asserts. **Removed.** `set_key_cert` called `SSL_use_certificate`, `SSL_use_PrivateKey` and `SSL_set1_chain` after `SSL_set_SSL_CTX`. It had no effect: `SSL_set_SSL_CTX` already installs the certificate of the context when it runs in time, and nothing helps when it does not. **Tests that also pass on main (20 of the 77).** They cover code that this PR adds: the 11 raw ClientHello rows of `ALPNCallback` (valid, boundary and malformed ALPN lists), the refusal alert after `setKeyCert()` (4), a handshake with no shared protocol version (2), and three TLS 1.3 rows where main was already correct (`keeps a refused client out of the handler` on two transports, `sees no cipher, session or peer certificate yet`). **Suites run on the debug build.** `node-tls-context.test.ts` (160 pass), `node-tls-server.test.ts` (217 of 218. `reports the reset that arrives while the socket is paused` fails in some runs of the whole file, also with the `src/` of main: 1 of 4 runs there), `node-tls-connect.test.ts` (4 failures, all need `bun.sh:443`), `node-tls-upgrade.test.ts`, `node-tls-cert.test.ts`, `node-tls-internals.test.ts`, `renegotiation.test.ts`, `test/js/bun/net/socket.test.ts` (2 failures that need `www.example.com`), `tls-fatal-error-closes.test.ts`, `tls-reject-before-client-cert.test.ts`, `bun-serve-ssl.test.ts`, `serve-http2.test.ts`, `serve-http2-protocol.test.ts`, `node-http2.test.js`. Vendored Node tests: `test-tls-server-setkeycert`, `test-tls-alpn-server-client`, `test-tls-psk-alpn-callback-exception-handling`, `test-tls-socket-constructor-alpn-options-parsing`, `test-http2-alpn`, `test-tls-client-resume`, `test-tls-client-resume-12`, `test-tls-ticket`, `test-tls-session-cache`, `test-tls-secure-session`, `test-tls-sni-option`, `test-tls-sni-server-client`, `test-tls-sni-servername`, `test-tls-snicallback-error`, `test-tls-empty-sni-context`, `test-tls-add-context`, `test-tls-client-verify`, `test-tls-server-verify`, `test-tls-peer-certificate`, `test-https-client-resume`. </details> =====STATUS===== **Status** Reproduction (fixtures in `test/js/node/tls/fixtures`): a `tls.Server` whose default context trusts ca1 and serves agent2, with an `ALPNCallback` that calls `this.setKeyCert(ctxB)` for protocol `b`. `ctxB` trusts ca2 and serves agent3. 1. Connect with ALPN `b`. On main the client gets agent2. With this PR it gets agent3. 2. On TLS 1.2, connect with the agent3 client certificate (issued by ca2) and ALPN `b`. Then offer that session with ALPN `a`. On main the handler sees `resumed: true, authorized: true` under the default context, which does not trust ca2. With this PR it is a full handshake with `UNABLE_TO_VERIFY_LEAF_SIGNATURE`. 3. Call `setKeyCert(ctxB)` from a `setImmediate` inside `ALPNCallback`. On main the handler sees `authorized: true` on TLS 1.2 and TLS 1.3, and the session resumes under the default context. With this PR the call changes nothing. Tests: `bun bd test test/js/node/tls/node-tls-context.test.ts` (77 new tests, 57 fail with the `src/` and `packages/` of main), and `bun bd test/js/node/test/parallel/test-tls-server-setkeycert.js`. PR: #43216
This was referenced Sep 18, 2026
Luligu
added a commit
to Luligu/matterbridge
that referenced
this pull request
Oct 20, 2026
README-BUN.md: mark the ws TLS and coverage known issues as fixed upstream (oven-sh/bun#44618, oven-sh/bun#45677), merged but not yet released. Matter.js-0.18.md: reference the released matter.js 0.18.0 and keep the dev snapshot audit history. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
robobun
added a commit
that referenced
this pull request
Oct 20, 2026
A TLS socket over a stream listened for 'drain' on that stream. The listener ran the socket's flush and its drain callback. Node's JSStreamSocket has no such listener. A stream that emits 'drain' inside its own write() ran that listener inside the send that called write(). NewSocket::internal_flush lends its queue to that send and settles it afterwards, so the nested flush sent the queue again: 524288 bytes for 262144, and a bad record MAC for HTTP/2 over such a stream. Bun 1.4.2 and 1.4.3 do this too. Since #44618 a TLS socket over a net.Socket that is still dialing is `connecting`, and node:net parks a write made then. The 'connect' of the socket and a drain of its handle each send that parked chunk. The listener delivered such a drain before the 'connect' when the dialing socket had a backlog of its own, so "hello" arrived as "hellohello". Without the listener no stock transport delivers a drain there. A net.Socket subclass that emits 'connect' after the handshake still does. The listener was also what sent the rest of one chunk above 2^31 - 1 bytes, because SSLWrapper::write_data takes at most that in one call. Socket.prototype._write now fails such a write with EPROTO on this engine, as Node does on every TLS socket. The HTTP/2 upgrade path sends it in slices, because its writer joins frames into one chunk that the user did not write. The two comments that said a send cannot reach the pending-write queue now say what can reach it over a stream. The deletion is the one of d7b42e1, without the write-done callback that commit put in the freed slot. When write(cb) completes does not change. Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
robobun
added a commit
that referenced
this pull request
Oct 21, 2026
A TLS socket over a stream listened for 'drain' on that stream. The listener ran the socket's flush and its drain callback. Node's JSStreamSocket has no such listener. A stream that emits 'drain' inside its own write() ran that listener inside the send that called write(). NewSocket::internal_flush lends its queue to that send and settles it afterwards, so the nested flush sent the queue again: 524288 bytes for 262144, and a bad record MAC for HTTP/2 over such a stream. Bun 1.4.2 and 1.4.3 do this too. Since #44618 a TLS socket over a net.Socket that is still dialing is `connecting`, and node:net parks a write made then. The 'connect' of the socket and a drain of its handle each send that parked chunk. The listener delivered such a drain before the 'connect' when the dialing socket had a backlog of its own, so "hello" arrived as "hellohello". Without the listener no stock transport delivers a drain there. A net.Socket subclass that emits 'connect' after the handshake still does. The listener was also what sent the rest of one chunk above 2^31 - 1 bytes, because SSLWrapper::write_data takes at most that in one call. Socket.prototype._write now fails such a write with EPROTO on this engine, as Node does on every TLS socket. The HTTP/2 upgrade path sends it in slices, because its writer joins frames into one chunk that the user did not write. The two comments that said a send cannot reach the pending-write queue now say what can reach it over a stream. The deletion is the one of d7b42e1, without the write-done callback that commit put in the freed slot. When write(cb) completes does not change. Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
robobun
added a commit
that referenced
this pull request
Oct 21, 2026
A TLS socket over a stream listened for 'drain' on that stream. The listener ran the socket's flush and its drain callback. Node's JSStreamSocket has no such listener. A stream that emits 'drain' inside its own write() ran that listener inside the send that called write(). NewSocket::internal_flush lends its queue to that send and settles it afterwards, so the nested flush sent the queue again: 524288 bytes for 262144, and a bad record MAC for HTTP/2 over such a stream. Bun 1.4.2 and 1.4.3 do this too. Since #44618 a TLS socket over a net.Socket that is still dialing is `connecting`, and node:net parks a write made then. The 'connect' of the socket and a drain of its handle each send that parked chunk. The listener delivered such a drain before the 'connect' when the dialing socket had a backlog of its own, so "hello" arrived as "hellohello". Without the listener no stock transport delivers a drain there. A net.Socket subclass that emits 'connect' after the handshake still does. The listener was also what sent the rest of one chunk above 2^31 - 1 bytes, because SSLWrapper::write_data takes at most that in one call. Socket.prototype._write now fails such a write with EPROTO on this engine, as Node does on every TLS socket. The HTTP/2 upgrade path sends it in slices, because its writer joins frames into one chunk that the user did not write. The two comments that said a send cannot reach the pending-write queue now state the condition: no JS that the send runs reaches the queue. The deletion is the one of d7b42e1, without the write-done callback that commit put in the freed slot. When write(cb) completes does not change. Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This was referenced Oct 21, 2026
robobun
added a commit
that referenced
this pull request
Oct 21, 2026
A graceful close of a TLS socket closed the fd at once when the kernel refused the close_notify. With input of the peer unread, that close is a reset, and the kernel drops what it has not sent. On Windows a write() followed by end() gets there: the kernel takes the write and refuses the next send. ssl_handle_shutdown no longer reports a spilled close_notify as a finished shutdown. The alert stays in the spill like any other record, the loop keeps the poll for writable of a shut-down TLS socket that still holds a spill, and the writable event sends it. That event is not dispatched to an owner whose close waits for the peer: the owner let go, and fetch answers such an event with a reset. A forced close does not wait for the alert. This reverses "close now without it" from #30368, which #44618 kept. The reason was that nothing sent the alert later.
Jarred-Sumner
added a commit
that referenced
this pull request
Oct 21, 2026
…6270) ### What does this PR do? Hardening follow-up to #46021. It fixes regressions from #46021 and #44618 that were never released, includes more of the open pull requests that fix a crash, and fixes a few crashes that had no pull request. #### Regressions fixed (none was released) - `spyOn(Bun, "$")`, and the same for `"sql"`, `"postgres"` and `"SQL"`, threw "does not support accessor properties yet". - Async `fs.read`, `fs.write` and `zlib.brotliCompress` on a view over a `WebAssembly.Memory` could lose data or copy far more than they needed. - `node:vm` with `vm.constants.DONT_CONTEXTIFY`: `this` in host accessors, prototype handling, `util.types.isProxy`, `Bun.deepEquals`, `assert.deepEqual` and `console.log` of a context. - `zlib` `stream.flush()` threw for arguments it used to accept, and streams had a new enumerable property. - `Bun.Transpiler` `exports.replace` with the name `"default"`. - `process.env.X = 3000` could store the number once the function was JIT-compiled. - `node:sqlite` and `bun:sqlite` could lose rows at exit. - Dev server: stylesheets shared between pages after a build error. - Shell `cp` of a file onto itself exited 1. - `error.stack`: the frame of `require()` reads `at anonymous (unknown)` again. - `import * as process from "process"` followed by `process.on(...)` threw at load. The EventEmitter methods that the module exports now act on `process`. - `delete res.statusCode; res.end()` in a `node:http` handler threw. It sends 200, as in Node. (from #46128) - `node:tls`, `node:https` and `node:http2`: connection teardown and write callbacks behave as in 1.4.3 and Node again. #### Crash fixes Bundler, parser and CSS: #42286, #46092, #46097, #46177, #46239, #36334, #43024, #40769. `bun test`, `--hot` and `--watch`: #30776, #38206, #38974. Shell: #43183, #37743, #46168, #46207, #34900. `node:http`, `node:http2`, `Bun.serve`, streams: #43449, #45380, #44457, #42197, #42485, #38082, #38124, #46074, #45999, #46045. `node:vm`, native addons, workers: #46129, #46198, #45615, #33412, #38031. Dev server: #42790, #42850, #39488. Other: #35991, #38030, #38586, #40526, #41830, #46029, #46078, #46114, and the bug of #42215. #### Behavior changes - `crypto.randomFill(view, cb)` reports `ERR_CRYPTO_OPERATION_FAILED` if it could not fill the view. It used to report success. - A function registered twice on `process` runs twice, as in Node. (#41830) - Under `--hot`, a reload after an uncaught exception cancels the timers that the failed run created. The new run's timers fire, and so do timers and `fs.watchFile` watchers that earlier runs kept on `globalThis`. The reload also resets the exit code to 0. (#38206) - The parent of a Worker no longer gets an `'error'` event for a failed `Bun.serve` request in that Worker. The error is printed, as on the main thread. - `hash.write()` after `digest()` drops the chunk, as in Node. (#35991) - `http2` over TLS with `server.emit("connection", socket)`: `session.socket` is a `TLSSocket` and a `'connection'` listener fires, as in Node. (#38124) - A `slice()` with no type of a body `Blob` has `type === ""`, as in Node. (#46114) - `util.types.isProxy(globalThis)` is false, as in Node. - A write on a `node:net` or `node:tls` socket that is still pending when the connection closes calls back with `ECANCELED`, as in Node. - `HTMLRewriter.on()` throws for a selector that is nested too deeply. - `Bun.Image`: `maxPixels` also bounds PNG metadata, `metadata()` of a GIF reports the size that gets decoded, and a path with a NUL byte throws. (#40526) - `bun build --no-bundle` with the `base64` or `dataurl` loader prints an error. (#36334) - Bundled output for a default-exported function or class expression changes to `var file_default = function name() {}`, as in esbuild. `.name` is unchanged. (#46097) - A Proxy in the prototype chain of a `node:vm` global is allowed. jsdom 30 with `runScripts` needs this. - `bun:sqlite` commits a write that `iterate()` left open when the main thread exits. An explicit `BEGIN` is still rolled back. - When a Worker stops while a native addon's async work is still running, the addon's finalizers run after that work is done, as in Node. - Shell: `mv -n` and `cp -n` don't overwrite, and `cp` reads `R`, `v` and `n` anywhere in a flag group. #### Performance - Ordinary `node:vm` contexts run at the same speed. With `DONT_CONTEXTIFY`, stores to globals and reads of inherited names in a hot loop cost 10 to 20 % more than on `main`. - A `process.env` write costs 20 to 35 % less than after #46021. - An async read into a `WebAssembly.Memory` view looks through the live memories: about 1.5 µs more with one memory. #### Not included - Taken in part, so these should stay open for the rest: #36334, #38974, #39488, #40526, #40769, #42485, #45615, #45999, #46029, #46092, #46114, #46168, #46207, #46239. - Needs a WebKit change: #34655 #39204 #40170 #40253 #41587 #41900 #42699 #42897 #42903 #43084 #43813 #44518 #44949 #45770 #45804 #45827. - #44723 (covered by #46097), #41675, #42753, #38762, #41475, #46016. ### How did you verify your code works? - Every item was reproduced first on release builds of `main`, of its parent and of 1.4.3, and compared with Node v26 where Node has the API. - Every fix has a test that fails on `main` and passes on a debug + ASAN build of this branch, except where that isn't practical (huge inputs, code that only runs on Windows or macOS). - The regressions were checked on grids of calls against the build before #46021 and against 1.4.3. - Bun's own test suites and Node's `test-vm-*`, `test-http2-*` and `test-zlib-*` files for the areas touched.
Jarred-Sumner
pushed a commit
that referenced
this pull request
Oct 21, 2026
…run (#46335) ### Problem - A callback of a TLS socket over a stream turns the event loop synchronously while the socket closes. Debug builds report `AddressSanitizer: heap-use-after-free` in `us_ssl_wrapper_keylog` under `SSL_do_handshake`, or in `SSLWrapper::flush_pending_events`. Release builds end with `panic: RefCell already borrowed` or stay silent. - `DuplexUpgradeContext::on_close` (`src/runtime/socket/socket_body.rs`) queued the task that frees the context at once. The nested turn ran it under the TLS engine. - Found by audit. No issue or crash report names it. ### Fix - `DuplexUpgradeContext` is refcounted. The open connection holds one ref, and each `UpgradedDuplex` entry point holds one while it runs. The free task is queued at zero. - No free task exists while an entry point runs. A debug assertion checks that handlers run under a ref. - Verified: `test/js/node/tls/node-tls-duplex-close-throw-uaf.test.ts`. Ten cases abort on the base build and pass with the fix. Also twelve TLS, http2 and module-graph suites (Notes). - Self-reviewed: 12 concerns raised, 11 addressed. One exit has no test (Notes). ### Background - `DuplexUpgradeContext` owns `UpgradedDuplex`, which owns the TLS engine (`SSLWrapper`) behind `tls.connect({socket})`. - `expect(promise).resolves` and `Bun.build` with an async plugin `setup()` run queued tasks while they wait. - Considered a check in the free task. It parks the free and polls. `WindowsNamedPipe` (#39983) holds a ref at its entry points. ### Downsides - Duplex TLS pays 9 instructions per guarded entry. An echo measures +0.11% instructions, and a control with no guard +0.06%. - A transport error before the engine starts takes 2 queue entries, not 1. - The binary grows by 8,192 bytes and one test-only host function. <details><summary>Notes</summary> #### What turns the loop, and what closes the socket - The wait is `EventLoop::wait_for_promise` (`src/jsc/event_loop.rs`). It calls `tick()`, which runs the task queue. The fixture uses two callers: a `bun:test` promise matcher in a plain script, and `Bun.build` with an async plugin `setup()` (`src/runtime/api/JSBundler.rs`). - The callback does not have to close the socket. Three of the ten cases close it from elsewhere: a `destroy()` in a later turn (the callback is the transport's `final()`), a transport that closes during the turn, and a `Bun.ModuleGraph` that is disposed. - #44618 covered an ALPNCallback that destroys its socket with no loop turn (it kept the `SSL` until the pass ends). The owner was still freed by the queued task. #### The ten cases on the base build (debug, ASAN) Each one ends in `heap-use-after-free`. The free is `DuplexUpgradeContext::deinit` from `run_event`, or from `release_unrun` for the disposed graph. | Case | Top frames of the read | |---|---| | ALPNCallback | `us_ssl_wrapper_keylog` < `us_ssl_keylog_cb` < `SSL_do_handshake` | | server 'keylog' | `SSLWrapper::flush_pending_events` < `traffic_pass` | | client 'secureConnect' | `UpgradedDuplex::is_shutdown` < `UpgradedDuplex::on_handshake` | | server 'data' | `SSLWrapper::dispatch_read` < `handle_reading` | | server 'data', turned by `Bun.build()` | `SSLWrapper::dispatch_read` < `handle_reading` | | client 'data' | `SSLWrapper::dispatch_read` < `handle_reading` | | the transport's `write()` | `SSLWrapper::handle_writing` < `write_data` | | the transport's `final()`, `destroy()` in a later turn | `UpgradedDuplex::finish_close` < `on_close` | | server 'data', the transport closes during the turn | `SSLWrapper::dispatch_read` < `handle_reading` | | server 'data', its `Bun.ModuleGraph` is disposed | `SSLWrapper::dispatch_read` < `handle_reading` | Release build of the base, original repro (ALPNCallback destroys the socket, then `expect().resolves`): `panic: RefCell already borrowed`, 5 of 5 runs. The 'keylog' variant printed OK 5 of 5 (silent). Sentry was not queried. #### How the tests decide - The fixture counts live contexts through `duplexTLSInternals.liveCount()`, so the tests do not need ASAN to fail. - Each callback case has a third context that nothing holds. The turn frees it. That shows that the turn ran the queued tasks: the report is `{ before: 3, afterTurn: 2, afterClose: 0 }`. With no turn it is 3, and with the fault it is 1. - The turn is a promise that a microtask settles, so one `tick()` runs. One case turns with `Bun.build()` instead, so the test does not depend on the matcher alone. - The fourth test ends a connection in seven ways and expects zero contexts left. It does not abort on the base build. There it fails because the counter does not exist. - Each guard clause was removed once on the debug build (by a jump in gdb) to confirm that a test notices: - no `teardown()` in `release_unrun`: a string from the transport reaches the disposed graph as `ERR_STREAM_WRAP`. - no `abort_handle.leave()`: `DuplexUpgradeContext entered after its last ref was released`. - the count check in `release_unrun`: the graph case reaches it with a count of 1, so an unconditional free trips the assertion in `deinit`. #### Direction - #33261 asks that nothing re-enters the event loop from a dispatch callback. #32233 was closed for that reason: it made the dispatcher tolerate re-entry. #33289 moves the `bun:test` matchers off the nested wait. - This change does not touch the dispatcher or the loop. It fixes a memory fault at one owner, in the shape of `REVIEW.md` ("bracket entry points that can reach synchronous teardown with ref()/defer deref()") and of #39983. - The trigger stays reachable after #33289: `Bun.build` still waits for an async `setup()` (#33271 was closed without a merge). #### Sequencing - This lands first. Two open PRs add entry points to `UpgradedDuplex` and need the guard after it: - #45471: `UpgradedDuplex::resolve_server_name` (an SNICallback that answers in a later turn) needs the `keep_alive()` guard as its first statement, and `DuplexUpgradeContext::on_server_name` needs the `debug_assert_kept_alive()` call. Without them the debug build aborts on the assertion. - #43877: the `on_write_done` thunk needs the guard. - #40139 rewrites the same lines and keeps the unconditional free in the `Close` arm. A rebase must keep the rule: the `Close` hop is queued only at zero. #### Not in this change - `WindowsNamedPipe`: #39983 guards `on_read`, `on_read_error`, `on_error`, `on_handshake` and `get_accepted_by`. Seven entries that dispatch have no guard: `on_writable`, `flush`, `on_timeout`, `encode_and_write`, `raw_write`, `close`, `shutdown`. No fault is known there: its transport is libuv and runs no script in write, end, pause or resume. Read from the code, not run on Windows. Do not copy `keep_alive` to those entries as is: there, a ref taken at zero queues the free twice. - `ProxyTunnel` (`src/http/ProxyTunnel.rs`) runs on the HTTP thread, where no script runs. - The start-failure exit of the `StartTLS` arm now releases the connection's ref. It has no test. `node:tls` and `node:http2` pass a prebuilt `SSL_CTX`, so the start fails only if `SSL_new` fails. #### Measurements (linux x64, base 71bc818) - Instructions: `perf` is not installed here. The counts are `perf_event_open` (`PERF_COUNT_HW_INSTRUCTIONS`, user only) around the release binaries. One TLS echo over an in-process Duplex pair, 100,000 round trips of 1 KiB, `BUN_JSC_useJIT=0`, 10 interleaved runs each. - base 32,160,640,513, this PR 32,197,252,048: +366 per round trip of 321,600 (+0.11%). Run-to-run spread: 0.09% to 0.13%. - The same echo over loopback TCP (no guard on the path): base 20,199,900,220, this PR 20,211,412,604 (+0.06%). Spread: 0.07% to 0.11%. - Disassembly of `UpgradedDuplex__flush`: 4 instructions at entry and 5 at exit. A round trip takes 4 guards (gdb count on the debug build). - `sizeof(DuplexUpgradeContext)`: 568 B -> 568 B (gdb on the debug builds). The two fields fit in padding. - Queue entries per duplex TLS socket (gdb hit counts on `run_event`, 50 cycles): normal close 2 -> 2, `destroy()` before the engine starts 2 -> 2, transport error before the engine starts 1 -> 2. - Live contexts: 2 of 3 inside the callback after the turn, 0 after unwinding. 0 after 1,000 cycles of each of three orders on the release build (4,000 sockets). - Release binary (`llvm-size`): `.text` 81,904,741 -> 81,909,861 (+5,120), `.rodata` +4,096, file 107,497,184 -> 107,505,376 (+8,192). Host functions: +1, testing only. #### Suites (debug build, `--timeout 90000`) Same results as the base build: `node-tls-duplex-end-verify` (115), `node-tls-duplex-write-throw-error-value` (1), `node-tls-wrapped-socket-close` (113), `node-tls-server` (218), `node-tls-upgrade` (21), `node-http2-upgrade` (23), `renegotiation` (58), `node-tls-context` (83), `tls-fatal-error-closes` (38), `tls-reject-before-client-cert` (126), `module-graph-io` (28), `module-graph-isolation` (413). `node-tls-connect` has the same 4 failures on both builds: those tests contact bun.sh, which this machine cannot reach. `cargo clippy -p bun_runtime` and `test/internal/source-lints/` are clean. </details>
dylan-conway
added a commit
that referenced
this pull request
Oct 22, 2026
…a lost connection, not a failed handshake (#46409) ### What does this PR do? Restores two recoveries that 1.4.3 has and `main` lost: `fetch` sending a request again after a reused https connection breaks, and `Bun.RedisClient` reconnecting after a `rediss://` connection breaks. #### What was wrong When a TLS session that is already up gets bytes it cannot accept (bytes that are not a TLS record, a record that does not authenticate) and the peer closes: | | 1.4.3 | `main` | |---|---|---| | `fetch`, GET on a reused https connection | sent again on a new connection, resolves | rejects with `EPROTO: The TLS handshake failed` | | `fetch`, GET through a reused CONNECT tunnel, the session with the **https proxy** breaks | sent again, resolves | rejects with `EPROTO` | | `RedisClient` over `rediss://`, a command in flight or the connection idle | reconnects | never reconnects, every later command rejects with `Connection has failed` | A FIN, an RST and a close_notify at the same moment are recovered from on both builds. #### Cause Since #44618, `ssl_fail_established_session` (`packages/bun-usockets/src/crypto/openssl.c`) reports a fatal error on an established session to the socket's owner through `on_handshake(success = 0)`, and then closes the socket itself. An owner that takes every such report for a refused handshake fails for good, before the close event that carries its recovery policy arrives: - `HTTPContext::Handler::on_handshake` called `close_and_fail`, so `HTTPClient::on_close`, which holds the resend rule, found a client that had already failed. - `JSValkeyClient`'s `on_handshake` called `fail_with_js_value`, which sets `flags.failed`, and `ValkeyClient::on_close` skips the retry policy for a failed client. The WebSocket client got its guard for this in #45720. #### Fix **fetch** (`src/http/HTTPContext.rs`, the fixing lines): a failed report for a request that `on_close` would send again returns without failing the client. uSockets closes the socket right after the report, and `on_close` does what it does for a FIN. `allow_retry` is only ever set on a reused connection, which is past its handshake, so a real handshake failure never takes this path. To ask the same question in both places, the condition moves out of `on_close` into `HTTPClient::retries_on_close()` unchanged (`src/http/lib.rs`). Everything that is not sent again keeps `main`'s behavior and its `EPROTO`: a POST, a stream body, a response whose body has started, `keepalive: false`. **RedisClient** (`src/runtime/valkey_jsc/`): a failed report on a connection that is `Connected` (its `HELLO` was answered), with `autoReconnect` on, is a lost connection. The commands in flight are rejected with the TLS reason, as on `main`, `failed` stays unset, and the close that follows runs the usual retry policy. The rejections are made without entering an event loop scope, so they are observed after `on_close` has run: a command issued from a `catch` waits for the new connection, as it does after a FIN, instead of going out on the dead socket. Two cases keep `main`'s behavior, the client fails and every pending command is rejected with the TLS reason: - **Before the reply to `HELLO`.** That is where a TLS 1.3 server refuses the client's certificate, after the client's side of the handshake is over, so a failure there is a refusal and not a lost connection. A client with no certificate gets `EPROTO: ...TLSV1_ALERT_CERTIFICATE_REQUIRED` from its first dial. (1.4.3 keeps dialing here: 9 connections in 8 s, with the command still pending.) - **`autoReconnect: false`.** There is no policy to hand the close to, and failing the client tells the commands that are still queued why, not only the ones in flight. #### Checked and left as they are - **The session inside a CONNECT tunnel** (origin side, `ProxyTunnel`). A request on a reused tunnel whose inner session ends is not sent again on `main`, but it is not on 1.4.3 either, and not for a FIN or a close_notify either. It is a different cause (`ProxyTunnel::on_close` never consults the resend rule) and older than #44618, so it is not part of this PR. - **HTTP/2 sessions**: `fail_streams` sends nothing again for any kind of close, so only the error code depends on the report. - **`Bun.SQL`**: the pool does not retry the close of an established connection whatever the error is, and dials again for the next query. - A finished TLS 1.2 renegotiation makes `RedisClient` send `HELLO` again. That is #45413. ### How did you verify your code works? Reproduced first on a debug build of `main` and on the 1.4.3 release binary with the same scripts (the table above), then on this branch. New tests, in the existing files for each area: `test/js/web/fetch/fetch-keepalive.test.ts` - a GET that a reused https connection answers with: bytes that are not a TLS record, a record that does not authenticate, half a response head and then such bytes, close_notify, FIN, RST. Each is sent again, and the origin sees exactly `connection 1 request 1`, `connection 1 request 2`, `connection 2 request 1`. - a POST, and a GET whose response body has started, are **not** sent again and reject with `EPROTO`. - a GET through a reused tunnel when the session with the https proxy breaks is sent again over a second tunnel. `test/js/valkey/reliability/connection-failures.test.ts` - the client reconnects when the session breaks in place of a reply (with the next command issued straight from the rejection) and while idle. `onclose` never fires, and the command in flight still rejects with `EPROTO: ...WRONG_VERSION_NUMBER`. - with `autoReconnect: false`, a `GET` in flight and an `INFO` queued behind it both reject with the TLS reason, and `onclose` fires once. - a TLS 1.3 server that requires a client certificate fails a client that has none at its first dial, with `TLSV1_ALERT_CERTIFICATE_REQUIRED`. | | without the `src/` change | with it | |---|---|---| | `fetch-keepalive.test.ts` | 4 fail (the three TLS breaks, the https proxy) | 58 pass | | `connection-failures.test.ts`, the 4 new tests | 2 fail (the two reconnects) | 4 pass | The existing tests that pin the errors of a broken session pass with the change: `test/js/bun/net/tls-fatal-error-closes.test.ts` (every owner still closes, same errors) 38 pass, `test/js/node/tls/renegotiation.test.ts` (a refused renegotiation, `fetch` and `RedisClient`) 57 pass. The close_notify / FIN / RST rows, the two "not sent again" rows and the last two Redis tests pass on both builds on purpose: they pin what must not change. The bug is newer than the latest release, so `USE_SYSTEM_BUN=1` passes these tests. "Fails without the fix" was shown by building `main`'s `src/` with the new tests. Each condition on the Redis side was removed in turn to see a test fail: without `status == Connected` the server that refuses the certificate is dialed again, without the `autoReconnect` condition the queued `INFO` gets `Connection closed`, and with an event loop scope around the rejections the command issued from the rejection goes out on the dead socket. The new Redis tests also pass under `BUN_JSC_validateExceptionChecks=1`. The files next to these that go through the same two `on_handshake` handlers, for real handshake failures (wrong name, untrusted chain, `checkServerIdentity`), also pass: `test/js/valkey/valkey-tls-verify.test.ts` 14 pass, `test/js/web/fetch/fetch.tls.test.ts` 64 pass. My machine has no IPv6, so for that run their servers listened on `127.0.0.1`. The one failure there, `trusts a self-issued certificate without keyCertSign only when ca holds that very certificate` (`CERT_SIGNATURE_FAILURE` where `UNABLE_TO_VERIFY_LEAF_SIGNATURE` is expected), is the same on `main` without this change. `connection-failures.test.ts` has one failure on my machine that is not from this PR: `close() over rediss:// while the peer has stopped reading closes the socket at once` fails the same way on `main` without this change. The tests there that need a Redis server were skipped (no Docker). --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Jarred-Sumner
added a commit
that referenced
this pull request
Oct 22, 2026
…tp2, and update brotli (#46392) ### What does this PR do? Fixes regressions since 1.4.3 that are still on `main` (none of them was released), and updates brotli. Fixes #46352 - **`bun install --frozen-lockfile` and `bun ci` refused lockfiles that 1.4.3 wrote**, so CI went red on the upgrade with nothing else changed: - a project with `overrides` whose lockfile has a version that an override doesn't allow. A frozen install installs what the lockfile says again. A plain `bun install` still applies the override. (from #45307) - a monorepo whose root names one of its own workspaces a second time: through an `npm:` alias, as an optional peer, by a dist-tag with `bun.lockb`, in two dependency groups, or by `file:` under another name. In one of these shapes a plain `bun install` also exited 1, or swapped the package. - **`bun update --interactive` could panic** (`index out of bounds`) after it had rewritten `package.json` and before it saved the lockfile, most often in a workspace with a `catalog`. (from #45307) - **`npx --quiet`, `-q`, `--no` and npx's other flags in a `package.json` script exited 1** under `bun run`. `bunx` skips the flags of `npx` and `npm` again. (from #44968) - **`fs.realpathSync.native(".")`, `fs.realpath.native(".")` and `fs.promises.realpath(".")` in a working directory that was removed** returned `"<dir> (deleted)"`. They throw `ENOENT` again, as in Node. (from #45423) - **`node:http2`**: connection teardown follows Node and 1.4.3 in one more case. (from #44618) - **HTTP/3** (`Bun.serve({ http3: true })` and the `fetch` HTTP/3 client): header size limits are now enforced, counted as in RFC 9114 §4.2.2. The server enforces the limit it advertises (`--max-http-header-size`, 16 KB by default) and answers `431` to a request over it. The client advertises 64 KB as before, accepts up to 256 KB like the HTTP/2 client, and rejects a larger response with `ResponseHeadersTooLarge` without disturbing other requests on the connection. A request may have 200 fields, as in HTTP/1.1 and HTTP/2. A header name or value over 65,535 bytes was sent cut short, with no error. Now `fetch` rejects with `HTTP3HeaderEncodingError`, and a `Bun.serve` response whose headers can't be sent resets its stream, where a response with more than 64 KB of encoded headers left the request hanging. `node:quic` reports such a failure too. `fetch` no longer sends a POST or PATCH a second time once it was written and its stream closed before the response headers: it rejects with `HTTP3StreamReset`. Idempotent methods, requests that weren't written yet and requests the server rejected with `H3_REQUEST_REJECTED` are still retried once. (from #42579) - **brotli is updated from v1.2.0 to v1.2.1**, which has the fix for CVE-2026-111497 in the decoder. The decoder's new error code is in `zlib.constants` as `BROTLI_DECODER_ERROR_FORMAT_BLOCK_SWITCH`, and `node:zlib` reports it as `ERR__ERROR_FORMAT_BLOCK_SWITCH`, as in Node. The error code changes and Node's regression test are from #46351 by @fredluz, which backports the same upstream commit as a patch. Thank you! #### Behavior changes in `bunx` - `bunx --registry <url>`, `--ca` and `--cafile` are new: they apply to the install that `bunx` runs. `--reg` and pnpm's `--config.registry=` mean `--registry`. - `--ignore-scripts` applies to that install too. - `--no`, `--no-yes`, `--yes=false`, `--yes false`, `-n` and `--offline` mean `--no-install`: a tool that is installed runs, and one that isn't is an error. 1.4.3 ignored them. - The other flags that choose a registry or its credentials (`--userconfig`, `--globalconfig`, `--npmrc-auth-file`, `--replace-registry-host`, ...) are errors. 1.4.3 ignored them. - `bun install`, `add`, `remove` and `update` parse their flags exactly as before. #### Not included - With the hoisted linker, the first install in a checkout without `node_modules` can leave out a workspace's `file:` dependency if that workspace is also reached under a second name. #45956 has that one. - A tag in one dependency group next to a version of the same workspace's name in another: some combinations behave as on `main`. ### How did you verify your code works? - Each item was reproduced first on release builds of `main` and of 1.4.3, and compared with Node v26 where Node has the API. - Each fix has a test that fails on `main` and passes on 1.4.3 and on a debug + ASAN build of this branch. - `overrides`: 6,398 generated lockfiles (both linkers, `bun.lock` and `bun.lockb`). `main` refuses 992 that 1.4.3 accepts, this branch none. Everything that `main` accepted has the same exit code, lockfile bytes, tree and output. - Workspaces: 3,011 generated projects with lockfiles from 1.4.0, 1.4.2, 1.4.3 and `main`, 12 steps each (frozen, plain, `bun ci`, `--production`, `--omit`, a workspace added later). Outside the shapes above, the only difference from `main` is a frozen install right after a workspace was added next to an alias or a tag of its name. It exits 0, as on 1.4.3. - `bunx`: 222 flag spellings called four ways on 1.4.3, `main` and this branch, and two local registries that log their requests. 1,584 `install` / `add` / `remove` / `update` calls give the same result as on `main`. - brotli: Node's regression test fails on `main` and passes here. The `node:zlib`, `CompressionStream` and compressed `fetch` tests and Node's own brotli tests pass. --------- Co-authored-by: Claude <noreply@anthropic.com>
dylan-conway
added a commit
that referenced
this pull request
Oct 22, 2026
) ### What does this PR do? On macOS, `send()` can return a number larger than the length it was given. Bun trusted that number: a TLS write crashes in `ssl_emit` and a plain socket write panics. Such a send now fails with `ECONNABORTED` and the connection is closed. **Cause.** XNU leaves the result register untouched when a system call ends in `EJUSTRETURN` ([x86_64](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/dev/i386/systemcalls.c), [arm64](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/dev/arm/systemcalls.c)), which [`ip6_output()`](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/netinet6/ip6_output.c) returns for a packet pf consumed. x86_64 reads back the system call number, arm64 the descriptor. golang/go#61060 reports the same. **Fix.** How much the kernel took is unknown, so the stream cannot continue. `us_internal_reject_excess_send()` in `socket.c` checks the five calls to `bsd_send`, `bsd_writev` and `bsd_write2`. On such a count it: - fails the send; - shuts the write side down, because four of the writers take any failed send for backpressure and would send again; - asks for a writable event, because no write may close under its caller. The TLS layer already closes on that event. For a socket without TLS, `loop.c` now closes with `ECONNRESET` after the event's callbacks. The `ssl_emit` crash came with #44618. Not covered: - On arm64 the descriptor is usually below the length, so the count still passes as a short write. So does a write of 32 MiB or more on x86_64. - `bsd_sendmsg` (unix sockets), UDP and QUIC sends are unchanged. ### How did you verify your code works? A new fault injection action, `excess`, makes `send` or `writev` report more than it was given. Debug build on Linux x64, one part of the fix undone per column: | Test | No check | No shutdown | No close | This PR | | --- | --- | --- | --- | --- | | `node:tls`, `fetch` over https, `wss:` | ASAN error in `ssl_emit` | pass | pass | pass | | `node:net` write | panic in the write path | pass | pass | pass | | `Bun.connect`, peer never answers the FIN | `write` returns 6 | the next write is sent | times out | pass | | `Bun.serve` response (`send`, `writev`) | times out | pass | pass | pass | | WebSocket server frame, reads paused | times out | pass | times out | pass | The seven touched test files pass. Not run on macOS. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
robobun
added a commit
that referenced
this pull request
Oct 22, 2026
A TLS socket over a stream listened for 'drain' on that stream. The listener ran the socket's flush and its drain callback. Node's JSStreamSocket has no such listener. A stream that emits 'drain' inside its own write() ran that listener inside the send that called write(). NewSocket::internal_flush lends its queue to that send and settles it afterwards, so the nested flush sent the queue again: 524288 bytes for 262144, and a bad record MAC for HTTP/2 over such a stream. Bun 1.4.3 does this too. Since #44618 a TLS socket over a net.Socket that is still dialing is `connecting`, and node:net parks a write made then. The 'connect' of the socket and a drain of its handle each send that parked chunk. The listener delivered such a drain before the 'connect' when the dialing socket had a backlog of its own, so "hello" arrived as "hellohello". Without the listener no stock transport delivers a drain there. A net.Socket subclass that emits 'connect' after the handshake still does. The listener was also what sent the rest of one chunk above 2^31 - 1 bytes, because SSLWrapper::write_data takes at most that in one call. Socket.prototype._write now fails such a write with EPROTO on this engine, as Node does on every TLS socket. An HTTP/2 session writes through the handle: there the rest of such a batch now waits for the next write of the session. The two comments that said a send cannot reach the pending-write queue now state the condition: no JS that the send runs reaches the queue. The deletion is the one of d7b42e1, without the write-done callback that commit put in the freed slot. When write(cb) completes does not change. Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Consolidates the open TLS pull requests into one. Each was reproduced on
mainand, fornode:*behavior, on Node v26.3.0 first. About a third are ported as written, the rest are rewritten smaller or merged into one fix where several PRs patched the same cause. One commit per fix, so it can be read commit by commit.Fixes #43520, fixes #31396, fixes #43635, fixes #37193, fixes #43846, fixes #17932, fixes #41061, fixes #36887, fixes #31810, fixes #35240, fixes #32234, fixes #44365, fixes #43807, fixes #42280, fixes #44517.
Addresses #41856 (SNI and
servername; notcheckServerIdentityfor SQL), #24845 (the spin is gone, shown with fault injection on Linux; not run on macOS), #19754 (node-fetch forwards the agent's TLS options; the Kubernetes client itself was not run).The ones that matter most
mainhttps.request()with a client certificate sends it to a server it then refuses (wrong name,checkServerIdentity,destroy()in'secureConnect',terminate()inhandshake). A server can force it with a junk record behind its FinishedauthorizedsecureConnectwithauthorized === truefor a peer that failed the key proof;secureConnectfor a plaintext peer withrejectUnauthorized: falsehttps.createServer()without a usable key/cert answers plain HTTPcrl, so a revoked client isauthorizedhttps.Agentsocket and sessiontls: [...]given toBun.listen/Bun.connectis plain TCPNODE_TLS_REJECT_UNAUTHORIZED=0turns off a server's client-certificate enforcementWebSocketnever callstls.checkServerIdentityand ignorestls.serverNameverify-fulldroppedPGSSLMODE=verify-*is lost next to aTLS_*URL variable;tls: truesends no SNIdestroy()inALPNCallbackover a Duplex;abort()on a latesetSession(); SIGABRT infetchwith an https proxy from the environment and aBun.file()bodywrite()can lose 16 KiB it reported as written while another socket on the loop is stalledsend(); a fatalSSL_writeleaves the socket open forever;idleTimeoutnever sheds a TLS client that ignoresclose_notifystop()/close()get the default certificate and skip their entry'srequestCert/cafetchupload through CONNECT: 1.7 s → 0.18 s (debug build)By area
openssl.c,socket.c): usockets: resume a paused TLS socket when its graceful close waits for the peer #42352, usockets: close a TLS socket whose send() keeps failing instead of spinning the writable dispatch #34510 + tls: close the connection when a post-handshake SSL_write fails instead of leaving it open #38176 + tls: report a rejected send() as a write error instead of a clean close #42336 as one change, tls: queue unsent ciphertext per connection so write() counts every sealed record #44529, fetch: send a Bun.file() body over TLS and CONNECT tunnels in bounded chunks #44458, Bun.connect, Bun.listen: document when a TLS socket closes with no handshake call #44192. A rejectedsend()ends the write side only and closes at the next writable event unless the peer's bytes are still queued (a 413 sent before a reset is still read). No new per-socket state. Also, on kqueue, a FIN no longer ends a socket that waits in the low-priority queue (loop.c): with more than 5 TLS handshakes at once, a client that ended right after its handshake could be reset and its server socket reportsocket hang up, because the eof that the sentinel read knote reports was acted on ahead of the unread Finished. That is onmaintoo (the macOS entry fornode-tls-server.test.tsintest/flaky-tests.txt: 7 of 48 recent builds of other branches), and this branch made it likelier (6 of 8 builds), since Finished now leaves in one segment with the close_notify.test/js/bun/net/tls-fatal-error-closes.test.tsasserts closed-and-nothing-delivered for every owner (node:tls,Bun.connect,Bun.listen,fetchdirect and through CONNECT,Bun.serve,WebSocketdirect and through a proxy, Postgres, MySQL, Valkey, Duplex).SSLWrapper,UpgradedDuplex): tls: free a stream TLS engine's SSL after its handshake pass has returned #44462, node:http2: keep frames whole and sent once over a TLS socket that wraps a Duplex #43529, tls: pause and resume a wrapped Duplex transport with the TLS socket's reads #42332, tls: give SSLWrapper its own queue-backed BIO instead of two BIO_s_mem #44464. node:tls: complete write(cb) and end(cb) over a Duplex when the stream completes the ciphertext #43877 + tls: remove the 'drain' listener on a wrapped stream #44394 were in and are out again, see "Worth a look" 5.net.ts,tls.ts): node:tls: attach the onConnectEnd 'end' listener once per connect() #38007, node:net: emit 'close' on a server TLS wrap whose handshake fails on the stream-level engine #38058, node:tls: destroy the wrapped net.Socket when a TLS wrap is destroyed #38028 + node:tls: report a wrapped socket's connect failure on the TLSSocket #38122 + node:tls: use the stream engine for server-side wraps the native upgrade cannot adopt #38076 as one change (six copies of the attach code become two helpers), node:tls: keep the handle attached while a client handshake protocol failure is reported #38311, node:tls: let connect() re-dial a TLSSocket whose connection is still open #39008, node:http2: destroy the accepted socket when an injected connection's TLS proxy is destroyed #38154, node:tls: emit 'connect' on a TLSSocket that wraps a connecting socket #42340 + node:tls: clear connecting on a TLSSocket that wraps a connected socket #42343 + node:tls: send the FIN when end() runs before the TLS handle is attached #42339 + node:net: send the FIN when end() on a socket runs around its TLS wrap #42453 as one change, node:tls: report a net.Socket transport's error on the TLS socket #43791, node:tls: ignore onread when the TLS socket wraps an existing socket #42425, node:tls: call checkServerIdentity with the connect options asthis#44085, node:tls: run a server-side TLSSocket's own ALPNCallback when socket.server is set #42683, node:tls: report servername as false on server sockets when the client sent no SNI #39088, node:tls: honor setServername() called before connect() #39040, test(tls): cover pause() and pauseOnConnect around the handshake on accepted and dialed sockets #40375, and what was still real of net: stop surfacing TLS traffic on the socket that tls.connect({ socket }) / new TLSSocket(socket) took over #36534.tls.Serverhas oneSSL_CTX.ws, node-fetch: node:http: http.Server ignores TLS options, https.Server always speaks TLS #41672 (https half), node:https: forward crl, sessionTimeout, ecdhCurve, sigalgs and honorCipherOrder to the TLS listener #41641, node:http: enforce blockList on https servers and on server.blockList assignments #38261, node:https: key the Agent pool name by what object-valued and file TLS options hold #42498, node:https: handle invalid TLS options in proxied requests #44346, node-fetch: forward TLS options from the agent to fetch #35609, ws: forward top-level TLS options (rejectUnauthorized, ca, ...) to the connection #31397, ws: unseal tls.pfx into key/cert before the native WebSocket connects #42325.Found on the way and fixed here: an upload that a TLS 1.2 server interrupts with a renegotiation never completes on
main(0 of 32 runs overhttps.request,fetch,node:tlsandBun.connect: the renegotiation ClientHello lands inside an application record that is still unsent, or the socket gets nodrainagain) and completes here, with two tests from robobun; the fix for #40653 (final flight and first write in one segment) stopped working whenever another TLS socket on the loop was stalled, onmaintoo; thetls.Serverprototype pinned the last server constructed and everySSL_CTXit owned;Object.create(process.env).NODE_TLS_REJECT_UNAUTHORIZED = "0"turned verification off process-wide once aSHARE_ENVworker existed; two debug panics when wrapping a shut-down or still-connecting socket; afetchPOST through a proxy sent its headers twice when the origin renegotiated;BlockListignored IPv6 zone ids; a test now tiesroot_certs.dertocertdata.txt.Behavior changes
cawithoutrequestCertno longer asks for a client certificate (Bun.serve,Bun.listen, HTTP/3, node:tls). It matches the docs and Node. Onmainsuch a server refused clients with no certificate but served any unrelated self-signed one, so it was never authentication. SetrequestCert: trueto require a certificate. A matrix test pins thatrequestCert: truestill refuses no certificate and an untrusted one on 8 kinds of server, TLS 1.2 and 1.3, withNODE_TLS_REJECT_UNAUTHORIZEDunset and0.NODE_TLS_REJECT_UNAUTHORIZED=0no longer relaxes a server.Bun.connect/Bun.listenhear of a fatal TLS error after the handshake througherror(socket, err). With noerrorhandler the socket just closes.*.covers exactly one label, case is ignored, a trailing dot is ignored, the last registration of a name wins.requestCerton node:https is=== true, as in Node.tls: [],jest.useFakeTimers([])).key/certarrays serve every identity. A client that can use both gets ECDSA, wheremainserved whichever pair came last.ecdhCurveis forwarded by node:https,wsand node-fetch now, so a group BoringSSL lacks (X448) throws there as it already does intls.createServer.raw.destroy(err)with a listener onrawonly is uncaught, as in Node.sql.options.tlsis always an object, nevertrue.RedisClientsends SNI.tls: { secureContext }alone asks for TLS onBun.listen/Bun.connect(it was plain TCP), and a value that is not aSecureContextthrows. The context is served as it is: therequestCert/rejectUnauthorizedit was created with hold whatever the options next to it say, andrequestCertin the options over a context that does not ask throws atlisten().tls.DEFAULT_CIPHERSreaches every client once assigned (fetch,WebSocket,Bun.connect,RedisClient,Bun.SQL,S3Client, proxy tunnels) and servers again. A list that selects no cipher throwsERR_SSL_NO_CIPHER_MATCHat the assignment.fetch.preconnect()dials nothing after an assignment.NODE_EXTRA_CA_CERTSis Node's one line, without thewarn:prefix.BUN_CONFIG_WS_CLOSE_TIMEOUT(default 30 s): how long aWebSocketclient waits for the server to close the connection after the closing handshake.Worth a look in review
tls: queue unsent ciphertext per connection so write() counts every sealed record #44529: the kernel-refused remainder of a TLS write moves from the loop's one slot onto the connection (in the existing rare struct), so the write BIO never refuses a sealed record. Nothing is allocated on an unstalled path (200 writes: 0 appends, same
send()count asmain), memory with 16 stalled writers is lower than onmain(276 KB vs 340 KB, whichmainholds inside BoringSSL's buffers),us_socket_tstays 80 bytes. It needs a bound on how long a deferred close waits, or a peer that stops reading pins the fd pastdestroy():US_SSL_CLOSE_AFTER_SPILL_TIMEOUTis a fixed 10 s, not re-armed on progress. Separate commits, but the fix that keeps the client certificate off the wire beside a stalled socket builds on them.The default name check of node:tls also runs inside the handshake, so a wrong-name server gets no client certificate on TLS 1.2 either. JS still runs it after every successful handshake, so a difference between the two matchers can only refuse. Error objects are byte-identical.
tls: trust a pinned self-signed certificate whose keyUsage lacks keyCertSign #44441 widens trust by design: a self-issued leaf whose
keyUsagelackskeyCertSign(dotnet dev-certs) is its own anchor when the store holds a byte-identical copy. No BoringSSL change. Expired pin, same subject with another key, wrong EKU and a pinned intermediate are tested to fail.node:tls: accept Ed25519 server certificates during client verification #32235 only adds Ed25519 and ECDSA P-521 to the verify list. A captured ClientHello shows
main's list with the two inserted;rsa_pkcs1_sha1stays.A stream that a TLS socket wraps, when that TLS socket closes. An earlier state of this branch lost data here while CI was green (found by tls: over a Duplex, the peer's close_notify waits for the writes in flight #44709's report): with the peer closing first, 4 of 8 MiB arrived with TLS in TLS, 4 of 32 MiB on the http2
emit("connection")path, and awrite()with no'error'listener ended the process. Three Node-parity changes only hold together: destroying the wrapped stream at the close (node:tls: destroy the wrapped net.Socket when a TLS wrap is destroyed #38028 + node:tls: report a wrapped socket's connect failure on the TLSSocket #38122 + node:tls: use the stream engine for server-side wraps the native upgrade cannot adopt #38076, node:http2: destroy the accepted socket when an injected connection's TLS proxy is destroyed #38154) is safe only if every write has really completed (node:tls: complete write(cb) and end(cb) over a Duplex when the stream completes the ciphertext #43877), which in turn needs Node's handling of the peer's close_notify, which needs half-open sockets that the GC can collect. So:main.destroy(). The same for an http2 socket the application never got, and forresetAndDestroy().main's teardown: anet.Socketonly gets the engine'send(), closes at its peer's FIN, keeps its own timeout and reports its own errors. Any other stream is destroyed with the TLS socket.The regular suites cannot see any of this (999 files were green on every broken variant), so it was steered by eleven seeded differential fuzzers run on this build,
main, Node v26.3.0 and the earlier state: close,end(),destroy(),destroySoon(), resets, hung and half-open peers, paused writers, timeouts, two and three sessions deep, over TCP and over Duplexes, before, at and after the handshake, and http2 requests. See "How did you verify".Known limits
fetchwith acheckServerIdentityfunction still sends the client certificate (not the request) to a server the function refuses. On TLS 1.2 any verdict a JS callback gives is too late, as in Node.addContext()/SNICallbackstill do not apply to a server-side socket on the stream engine (emit("connection", duplex), TLS in TLS, unflushed writes, named pipes), as onmain.caonly, forws/ node-fetch /WebSocket: the nativecacan only replace the default store, and that store keepsSSL_CERT_FILE/SSL_CERT_DIR.it.todo).tls.DEFAULT_CIPHERSis assigned,fetch(url, { protocol: "http3" })isHTTP3Unsupported, as with an explicitciphers.addCACert()by hand does not extend the chains of a context with several identities.ALPNCallbacksendsno_application_protocolon both engines. Node sends nothing and its client seesECONNRESET.write EPIPE, where Node givesECONNRESET(maingives it no error at all).@SECLEVELinciphersis dropped by thews/ node-fetch shims, which used to ignoreciphers. node:tls keeps throwingERR_SSL_INVALID_COMMAND.'error'and then'close'. Node emits'error'and leaves the socket open.EPIPE, as on Node.mainreports no error there and delivers it.maintoo: aBun.listensocket withoutallowHalfOpenthat has unsent ciphertext when the client'sshutdown()arrives loses that ciphertext (32 KiB), and over plain TCPend()with the peer still sending is a close over unread input, so a reset.mainand Node that the differential runs below found and that stay, all with a peer that aborts:ECONNRESETinstead of a clean'end'after the socket's own'finish'when the peer destroyed with unread data; under TLS 1.2, a zero-lengthwrite()followed bydestroy()in'secureConnection'leaves the client without'secureConnect'(a plaindestroy()there matches Node); a TLS 1.2 client that destroys in'secureConnect'gets no'session'; aClientRequestwhose handshake fails with an alert emits'error'and'close'but no'finish'(writableFinishedis true).tls.DEFAULT_CIPHERSis assigned,fetch.preconnect()opens nothing:fetch()then uses a context of its own, and a socket warmed under the default one would never be picked up.send()that the kernel refuses outside awrite()call (the drain of unsent ciphertext) is reported with the close, asread EPIPE/read ECONNRESET. Node sayswrite EPIPE.maindoes not report it at all.net.Socket(TLS in TLS, http2emit("connection")), a peer that never sends its FIN holds that socket after the TLS socket closed, as onmain. Node destroys it. Two tests of node:http2: destroy the accepted socket when an injected connection's TLS proxy is destroyed #38154 aretodofor this. Closing it any earlier (at its'finish', say) makes the kernel drop what it has not sent yet as soon as the peer's close_notify arrives.destroySoon().mainsends it.net.Socket,end()can still cut what that stream has buffered, and there is no backpressure, both as onmain(node:tls: complete write(cb) and end(cb) over a Duplex when the stream completes the ciphertext #43877).tls.secureContext(the undocumented door node:tls uses) is not read by a Windows named pipe listener, which builds its context from the options. OnupgradeTLS({ isServer: true })the options next to it are the policy, as with Node'sSetVerifyMode.selectServerName()rebuilds the name tree per ClientHello for injected sockets of a server withaddContext()entries: 0.4 µs for 1 entry, 3.7 µs for 10, 41 µs for 100, against 631–1111 µs for a handshake.Not included
Left open, because they need a decision or are not TLS: #43877 + #44394 (see "Worth a look" 5; #43874 stays open with them), #38548, #38591 (both shrink who is trusted), #41589 (
verify-fullvsNODE_TLS_REJECT_UNAUTHORIZED=0), #37197, #41706, #43216, #33487, #33545, #36707, #32435, #37255, #28691, #40275, #30314 (features), #38120 (needs the BoringSSL fork, as did #33517, which the stale bot has closed since), #38529 (needs a Windows measurement), #34342, #38232, #43089, #44454, #40451, #42710, #44527, #38088, #38093, #41898. #37896, #42054 and #37013 stay open for the halves not taken.http.createServer({ key, cert })keeps serving TLS on purpose.One open question:
tls: {}(an object that names no TLS option) is plain TCP onBun.listen/Bun.connect, here and onmain. It is the same trap astls: [], but changing it changes a Bun default, so it is left alone.How did you verify your code works?
mainfor the stated reason and passes here, except guards that pin existing behavior, each shown to fail when its clause is removed.node:*tests also pass on Node v26.3.0; the few that cannot say which Node version has the behavior.maintoo:serve.test.ts"root range port" (the box runs as root), andworker_threads.test.ts"terminate(): nothing of the worker's runs after the request", which is flaky there and passed in the run below.BUN_JSC_validateExceptionChecks): 58 files, 48 of them with leak checking, 4132 pass, 3 fail. All three also fail onmain:serve.test.ts"root range port",node-net.test.ts"should not leak when connect({path}) fails synchronously on a reused handle" (times out under this environment),worker_threads.test.ts"process.exit() with a shell cp in flight" (aShellCpTaskleak).test-tls-*,test-https-*,test-net-*,test-http2-*: the only two failures also fail onmain.mainand Node v26.3.0: cleartext https,crl,tls: []/{ secureContext }, theca/requestCertmatrix, the client certificate on a wrong-name server, latesetSession(),destroy()inALPNCallback,[rsa, ec]identities with an intermediate fromca,WebSocketcheckServerIdentity, a corrupted record, the Duplex read above,tls.DEFAULT_CIPHERS.setSession()guard was checked against the realabort()at 43 handshake states.bun run rust:check-all: 12 of 12 targets.tsc, oxlint, source lints, prettier, rustfmt, mordant clean._Nonnullis compiled out of debug builds, so 105 of those files were also run on a local release ASAN build with the CI runner's environment (92 with leak checking): 4595 pass, 1 fail,child_process.test.ts"spawn reports EPERM after dropping privileges", which cannot pass as root and fails onmaintoo.main, on Node v26.3.0 and on the earlier state of the branch. Againstmain:maindelivers in full is cut in 5 scenarios, and about 150 thatmaincuts arrive in full. Of the 5, in 2mainnever notices the peer's close and keeps the socket for good, 2 callend()on the middle one of three sessions over an in-memory Duplex, and 1 does the same on Node.maindoes not have (4 uncaught errors fewer).maincloses it in 109, and closes wheremainkeeps it in 295. 92 of the 109 do the same on Node or on the earlier state (adestroy()that an in-memory Duplex does not show its peer, half-open peers). 14 wait for a peer that paused reading and so does not read the FIN (tls: pause and resume a wrapped Duplex transport with the TLS socket's reads #42332's backpressure, as in Node); the socket's own timeout fires there. 3 are left: one on a 5 ms timer, two with three sessions over an in-memory Duplex.maincuts none and this cuts none.openssl.c, the path offetch,Bun.serve,tls.connect,Bun.connect) got the same treatment after the rebase: seeded differential fuzzers on CI's release build of this branch, onmainand, fornode:*, on Node v26.3.0. Every runtime also against itself for the noise floor, injected faults and known bugs ofmainas positive controls, and a difference counts only if it shows in 5 of 5 fresh processes.node:tlsover TCP: 11,500 scenarios (one connection with Node as the oracle line by line; 2 to 60 connections beside stalled neighbours; raw peers that break the handshake). HTTPS: about 136,000 runs overBun.serve+fetch,node:https,node:http2andwss://, also with the two ends in different runtimes.Bun.connect/Bun.listen/upgradeTLS: 11,500 scenarios and 720 slow connections, with writers driven by whatwrite()returns, beside up to 6 stalled, dripping, closing or resetting neighbours, and plain TCP as a second oracle. No crash, hang, duplication, reordering or silent truncation, and no change in time or in connection reuse.maindoes better, none of which any test showed. All are fixed, each with a test that fails on the build before: what the peer sent lost behind a rejectedsend()(23 scenarios, and an early HTTPS response lost with onlyEPIPE), the same silently for a paused reader,server.close()never calling back on a half-open server after a ClientHello and a reset (17),closeAllConnections()taking 12 s with a stalled client,end()losing up to 1.3 of 4 MiB thatwrite()had reported while the peer still uploads, andend()a little after a stall never closing beside other stalled TLS sockets. The last two fixes also deliver the 1 to 2 MiB thatmainloses there, and close the socket thatmainkeeps for good without such neighbours.maindoes not 23 → 2, and Node loses it in both, with the sameEPIPE;server.close()that never calls back 17 → 0; connections held 4 → 0; requests that end in an error only wheremainhas a response 6 → 0. With a Node server in another process, a request ends in an error only in 8 and 10 of 1,500 scenarios here, 5 and 3 onmain, 10 with Node as the client.Bun.connect/Bun.listenon the last build againstmain, in scenarios: hangs 0 against 1,031, sockets and fds never released 0 against 965, corrupted data 0 against 345,abort()0 against 26 (setSession()after the handshake), writers that never close 0 against 101 of 720 connections. No kind of failure shows here and not onmain. About a third of the slow connections close later than onmain, in 1 to 16 s instead of at once, waiting for unsent ciphertext or for the peer's close_notify, and 79 more of them deliver all thatwrite()reported. RSS and time with 16 to 256 stalled writers are the same.maindoes worse: aWebSocketthat callsclose()with sends pending loses messages in 81 of 999 scenarios (0 here), 37 server sockets left open, 10server.close()that never call back, 20 write callbacks that never run.connectionListenercount test now says what became of a missing connection, which is how the cause was found ('tlsClientError'"socket hang up", thenread ECONNRESETat the client of the same port, after its'secureConnect'). On macOS x64 it failed every attempt of the three builds before the fix and passed at the first attempt of the build with it.fstat()on a socket) and now say so per platform.