Skip to content

node:tls,node:net: follow-ups from the v26.3.0 review (error routing, manualStart reads, handshake timeout, setSecureContext) - #35006

Merged
Jarred-Sumner merged 77 commits into
mainfrom
claude/node-tls-26-followups
Jul 23, 2026
Merged

Jarred-Sumner merged 77 commits into
mainfrom
claude/node-tls-26-followups

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 21, 2026 •

Copy link
Copy Markdown
Member

What this does

Follow-up to #32630, stacked on claude/node-tls-26-compat. It closes the non-blocking divergences listed in the v26.3.0 review of that PR — the ones explicitly deferred as "worth a look, or a follow-up". Every behavior here was pinned against a built node v26.3.0 binary first, and each comment cites the lib/internal/tls/wrap.js (or lib/internal/stream_base_commons.js) line it mirrors.

The base PR's four blocking items are already fixed on its own branch; nothing here re-touches them.

Fixes

  • Error routing now uses node's _tlsError / _emitTLSError / _releaseControl trio. Node's _init installs _tlsError as the first 'error' listener and _releaseControl() removes it once the socket is handed to the user (tls.connect releases immediately; a server socket releases just before 'secureConnection'). That single mechanism is what makes the next two items work without turning every failure into an uncaught exception.
  • A failed handshake now reports hadError === true on 'close'. Node's onerror destroys with the error when _secureEstablished is false and defers the teardown through _closeAfterHandlingError so the socket is still reachable while 'tlsClientError' is emitted. Bun destroyed without the error to avoid an uncaught exception — the internal listener above removes that need. Same for the rejectUnauthorized rejection path.
  • 'tlsClientError' is now driven by the '_tlsError' hop (onSocketTLSError, registered per accepted socket) instead of direct emissions scattered through the handshake/error handlers, guarded by node's !_controlReleased && !kErrorEmitted pair so it fires exactly once.
  • Server-side TLS sockets are no longer force-resume()d after the handshake. Node's server TLSSocket is manualStart: initRead() only read(0)s the handle, leaving readableFlowing === null, so bytes that arrive before a 'data' listener attaches (e.g. after an await in the secureConnection handler) are buffered rather than emitted with no listener. The base PR made this true for plain TCP; this makes it true for TLS.
  • The handshake timeout emits the socket's 'timeout' event and leaves the connection open. Node arms the deadline with socket.setTimeout(ms, _handleTimeout), so 'timeout' is what fires and _handleTimeout — its first listener — routes the expiry through _emitTLSError; 'tlsClientError' and 'timeout' both reach the user, in that order, and nothing is destroyed. Bun keeps a standalone timer rather than the socket idle timer because, checked against the binary, encrypted bytes arriving mid-handshake do not extend node's deadline.
  • server.setSecureContext() no longer touches _requestCert / _rejectUnauthorized. Those live on the Server and are re-read per connection by tlsConnectionListener; node's setSecureContext assigns only credential fields. Previously a server.setSecureContext({ key, cert }) on an mTLS server silently stopped it requesting client certificates.
  • A same-tick write after a server-side TLS wrap is delivered instead of failing. Node wraps the handle synchronously in the TLSSocket constructor, so a banner written in the same tick is buffered by TLSWrap and flushed after the handshake. Bun adopts the fd one tick later (deliberately — see node:tls: sync the test suite to Node v26.3.0 and fix the gaps it surfaces (+24 tests, 155→179 of 221 upstream passing) #32630), so the write is now held until the handle lands rather than hitting the !socket → ERR_SOCKET_CLOSED path.
  • A throwing onread callback is an uncaught exception again. onStreamRead calls the user callback bare, so node crashes the process; Bun converted it into destroy(e) + 'error'. Bun's native data dispatch catches JS exceptions, so the throw is re-raised out of the dispatch to escape as the uncaught exception node produces.
  • The handshake-timeout handler is retired once the handshake resolves. Node's _finishInit does setTimeout(0, this._handleTimeout), which clears the timer and removes the listener. Bun kept the deadline armed as a plain timer, so wiring the deadline to the socket's 'timeout' event (above) would have made any later idle timeout on an established keep-alive connection surface as ERR_TLS_HANDSHAKE_TIMEOUT — https.createServer + server.setTimeout() is enough to hit it. The handler is now removed wherever the timer is cleared, with a regression test proven to fail without the fix.
  • Server requestCert / rejectUnauthorized are assigned only by the Server constructor, matching node (this.requestCert = options.requestCert === true; this.rejectUnauthorized = options.rejectUnauthorized !== false). Two things fall out: requestCert: 1 no longer enables mTLS (node normalizes with === true), and a server's rejectUnauthorized default no longer consults NODE_TLS_REJECT_UNAUTHORIZED — that switch is client-side in node, and honoring it server-side meant NODE_TLS_REJECT_UNAUTHORIZED=0 in the environment silently downgraded an mTLS server to accepting unverified client certificates.
  • closeSocketHandle() deferred cleanup no longer derefs a null handle. The _closeAfterHandlingError path re-read self._handle inside its setImmediate; a second destroy() in between clears it, so the cleanup threw TypeError: null is not an object. It now uses the handle captured at entry. This is a latent bug on the base branch that the hadError change above makes reachable.

Deliberately not changed

  • Post-handshake TLS errors still destroy the socket. Node's onerror calls _emitTLSError and leaves the connection alive. Aligning this surfaced a separate pre-existing divergence: Bun's tls.Server completes the handshake and emits 'secureConnection' for a client that already rejected the certificate and reset, where node reports tlsClientError: ECONNRESET and never completes. With the node-faithful no-destroy routing, the resulting post-handshake error on that phantom connection becomes an uncaught exception (it is what test-tls-close-error asserts against). That ordering has to be fixed first — it is native, not JS — so this one stays as-is with a comment saying why.
  • The socket.ssl shim still exposes only verifyError() and fd, and the cipher-name allow-list still rejects strings composed solely of OpenSSL suites BoringSSL lacks. Both were flagged as acceptable library limitations in the review.

Tests

Six new cases in test/js/node/tls/node-tls-server.test.ts (node v26.3.0 tls.Server parity follow-ups): the same-tick write after a server-side wrap, readableFlowing === null plus post-handshake buffering for a late 'data' listener, hadError on a failed handshake with no 'error' listener, 'timeout' + 'tlsClientError' + still-open socket on handshakeTimeout, an idle timeout after a completed handshake not reporting ERR_TLS_HANDSHAKE_TIMEOUT, and an mTLS server still requesting client certificates after setSecureContext() (asserting the exact ERR_SSL_PEER_DID_NOT_RETURN_A_CERTIFICATE node reports).

test/js/node/net/node-net.test.ts's onread-throws test is rewritten: it previously asserted Bun's fail-closed behavior and even documented the divergence in its own comment ("Node has no catch here (the throw is an uncaughtException)"). It now asserts node's contract via a subprocess — the process dies, only the slice that threw was delivered, and no socket 'error' is emitted.

Verification

  • Against the node v26.3.0 binary: a single driver script exercising all of these scenarios plus three adversarial probes was run unmodified under node v26.3.0 and under this branch; the output matches line for line, with one documented exception (_handle is null synchronously after a server-side wrap, since Bun adopts the fd a tick later — the write it queues still lands). The probes are what caught the requestCert regression: a new tls.Server() later given setSecureContext({requestCert: true}) must still accept anonymous clients, because node's setSecureContext ignores the option entirely.
  • Vendored upstream suite: 330/332 test-tls*/test-net* files pass. The two failures (test-net-connect-keepalive, test-net-server-keepalive) were confirmed failing identically on the base branch with the same build.
  • Bun-side suites: test/js/node/tls/ + test/js/node/net/ — 450 pass, 0 fail.
  • Non-vacuous: the idle-timeout regression test was proven to fail with the one-line fix reverted.
  • Blast radius (node:http, node:http2, fetch, websocket, bun/net): 489 pass / 16 fail vs the base branch's 492 / 13 on the same files; every differing case is an external-network or GC-timing flake and passes in isolation (fetch.test.ts 353/0, abort-signal-leak 3/3).

no test proof · iteration 7 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/net/node-net.test.ts

cirospaciari and others added 30 commits July 6, 2026 18:18
…faces

Squash of the node:tls v26.3.0 compatibility work: vendored test sync,
net/tls runtime fixes, new SecureContext options (crl, sessionTimeout,
allowPartialTrustChain, sigalgs), strict client cert verification during
the handshake, and CA-supplied intermediate chain presentation.
…#33199)

### What

`new tls.SecureContext(options)` (the exported constructor) returned a
wrapper around the **digest-interned, shared** native `SSL_CTX`.
Mutating it therefore mutated every other context with the same
configuration digest:

```js
const a = new tls.SecureContext({ ca: ca2 });
const b = new tls.SecureContext({ ca: ca2 }); // independent object, same interned SSL_CTX
a.context.addCACert(ca1);
tls.connect({ secureContext: b, ... }); // b now trusts ca1 too
```

Against a server whose chain roots in `ca1` (not in the default roots),
**node v26.3.0 fails closed** (`UNABLE_TO_GET_ISSUER_CERT_LOCALLY`)
while Bun completed the handshake with `authorized=true` — an extra CA
silently trusted by connections that never asked for it. (Surfaced by a
security scan of this branch; reproduced end-to-end against both
runtimes before fixing.)

`createSecureContext()` already builds a **private** context for exactly
this reason ("a user-constructed context owns its SSL_CTX exclusively,
so addCACert can never leak across contexts"); the exported constructor
was the one user-constructible path that missed the invariant. It now
passes `cached: false` too. Internal paths
(`tls.connect`/`Server`/`fetch`) keep the shared cache: their contexts
are never exposed for mutation, and that sharing is the cache's purpose.

Regression tests (both in `ssl-ctx-cache.test.ts`, next to their
`createSecureContext` siblings): (1) two `new tls.SecureContext()`
instances with identical options get **distinct native handles** — the
interned cache handed both the same cell before the fix; (2) the
end-to-end scenario above — after `a.context.addCACert(ca1)`, a
connection using `b` still fails with node's exact
`UNABLE_TO_GET_ISSUER_CERT_LOCALLY` (on the unfixed code it completed
with `authorized=true`).

### The other scan findings that touch this PR stack's files (triaged,
not fixed here)

Each was verified against the sources before deciding:

- **`upgradeTLS` `initialData` used after re-entrant JS can free the
backing store** (`socket_body.rs`): real, pre-existing on `main` — and
since fixed upstream by #33388, so nothing is owed here anymore.
- **TLS 1.3 session resumption marks certificate-less clients as
verified** (`openssl.c`): the resumption arm pre-exists on `main` and is
a faithful port of Node's own `VerifyPeerCertificate`
(`src/crypto/crypto_common.cc`), which Node's server path also uses — so
Bun matches Node here by construction. Hardening beyond Node would be a
deliberate divergence decision.
- **TLS socket reports `authorized=true` despite failed chain
verification** (two findings, `socket_body.rs` `on_handshake`): the scan
itself notes the computation is byte-identical to upstream Bun; it
concerns the Bun-native `Bun.connect`/`Bun.listen` socket API (node:tls
and fetch enforce verification in their own layers). Changing a
Bun-native API contract needs a maintainer decision, not a drive-by in a
node:tls compat stack.

### Notes from the pre-PR review pass

- The exported constructor now simply delegates to
`createSecureContext()` (one source of truth for the ownership
contract), and the three comments that enumerated `createSecureContext`
as the *only* exclusively-owned constructor were updated so the
enumeration cannot rot into a regression.
- Informational, unchanged: a digest-cached native context with a
callable `addCACert` is still reachable if code deliberately digs out
the registered internal symbol (`::buntlsnativesecurecontextctor::`);
that is outside the public API surface.

---

Rebased onto the updated base branch (which itself was rebased onto
current `main`).

---------

Co-authored-by: Alistair Smith <hi@alistair.sh>
…ode-verbatim

- kReaderInterest destroySoon check now runs after a setImmediate so a
  connection handler that attaches its reader via nextTick / microtask /
  setImmediate still receives buffered bytes; the comment now states this
  is a deliberate divergence (Node also holds the loop for such sockets).
- close(hadError) reverts to Node's literal 'exception ? true : false'
  (lib/net.js:880); the previous OR-in of _hadError flipped
  close(false)->close(true) on the server 'peer did not return a
  certificate' path where Node emits close(false).
- drainOnreadTail honors an interleaved pause(): a resume()->pause()
  before the drain tick, or a pause() from inside the callback returning
  non-false, now leaves the handle stopped like Node's level-triggered
  _handle.reading.
- Reworded the onread slice-delivery comment to say WHY (uSockets shared
  512KB recv_buf) so the loop is not 'fixed' by removing it.

Adds accepted-socket buffering coverage (readable-listener + setImmediate
data-listener) and an onread resume->pause test.
…e; fix option layering

- authorizationError is now initialized to null in the TLSSocket
  constructor (lib/internal/tls/wrap.js:556) so client-side and
  no-requestCert clean handshakes report null like Node; the redundant
  server-handshake assignment in net.ts is dropped.
- tls.Server builds one _sharedCreds SecureContext (lazily, on first
  STARTTLS emit) from the post-normalized server fields and reuses it,
  instead of rebuilding a fresh un-normalized SSL_CTX per emitted
  socket. This also fixes the STARTTLS wrap path dropping the server's
  honorCipherOrder->SSL_OP_CIPHER_SERVER_PREFERENCE default.
- honorCipherOrder is folded into secureOptions inside
  newNativeSecureContext (Node common.js:108) so createSecureContext,
  addContext and SNICallback contexts carry it too.
- CIPHER_LIST_SELECTORS gains kPSK/aPSK/AES128/AES256/FIPS from
  BoringSSL's kCipherAliases so ciphers:'AES128' is not rejected before
  BoringSSL sees it.
- InternalSecureContext now validates crl via throwOnInvalidTLSArray, so
  createSecureContext({crl:123}) throws the same ERR_INVALID_ARG_TYPE
  as Server.setSecureContext.
- tls.connect gates the NODE_TLS_REJECT_UNAUTHORIZED fallback on
  key-presence, not === undefined: an explicit rejectUnauthorized:
  undefined now coerces to true via Node's spread-then-!==false and no
  longer honors the env var.
…OPERATION_FAILED

- The auto-chain walk is no longer gated on options.ca: Node clears
  SSL_MODE_NO_AUTO_CHAIN unconditionally (crypto_context.cc:1640) so
  intermediates from NODE_EXTRA_CA_CERTS / the default store are also
  chained. The 'BoringSSL has none' comment was wrong at Bun's pin
  (SSL_MODE_NO_AUTO_CHAIN exists and is off by default).
- us_ssl_ctx_add_ca_cert re-runs the auto-chain walk when the context
  has a leaf and no chain yet, so PKCS#12-bundled intermediates that
  reach the store via addCACert after the context was built are still
  presented (Node's LoadPKCS12 adds them via SSL_CTX_add1_chain_cert).
- create_bun_socket_error_t::invalid_crl now maps to
  ERR_CRYPTO_OPERATION_FAILED 'Failed to parse CRL' matching Node's
  SetCRL (crypto_context.cc:1893-1903), which uses ClearErrorOnReturn
  and no OpenSSL decoration.
…ch Node's onread buffer rules

An inherited `rejectUnauthorized` or `checkServerIdentity` could reach the
socket: `"x" in options` walks the prototype chain, so a polluted
`Object.prototype` turned certificate verification off, or installed its own
hostname verifier. Node merges `{...defaults, ...options}`, which copies own
properties only. Resolve both from own keys and hand the socket a merged clone
that carries Node's defaults, so an inherited value is never visible.

onread:
- A buffer factory that returns a non-Uint8Array keeps the previous buffer,
  and is handed the literal `true` until it yields one, like Node's kBuffer.
  A static non-Uint8Array buffer leaves the socket an ordinary 'data' stream.
  Previously bun passed its internal chunk through, or threw a TypeError.
- Hold a clean EOF behind a tail the callback has not taken yet: Node's
  readStop leaves those bytes, and the FIN behind them, unread in the kernel.
- The EOF nudge that makes 'end' fire now goes through Duplex.read, so it no
  longer redelivers a paused tail the callback has not asked for.

Also use the native isUint8Array from node:util/types instead of an
instanceof check, and collapse tls.connect()'s double options clone into one.
A SecureContext built with `ca` sets SSL_VERIFY_PEER|FAIL_IF_NO_PEER_CERT on
its shared SSL_CTX. The STARTTLS/adopt path only overrode the verify mode
when requestCert was set, so a cert-less client's handshake aborted with
PEER_DID_NOT_RETURN_A_CERTIFICATE on the server.emit('connection') path while
the same server's native accept path succeeded. Node's TLSWrap::SetVerifyMode
runs unconditionally on server sockets and forces SSL_VERIFY_NONE for
!requestCert; do the same.
…of throwing

Node returns whatever SSL_set_max_send_fragment returns: OpenSSL rejects a
size outside [512, SSL3_RT_MAX_PLAIN_LENGTH] with 0, which surfaces as false.
Bun's native binding hand-rolled the range check with a floor of 1 and threw
a codeless Error, so setMaxSendFragment(0)/(16385) threw where Node returns
false, and 2..511 were accepted where Node rejects them. BoringSSL clamps
into the range and always returns 1, so the rejection has to live here.
Socket.prototype._destroy read `this.server`, which the STARTTLS wrap sets
(like Node's tlsConnectionListener) even though server.emit('connection')
never runs the onconnection increment. A never-listened tls.Server used as a
STARTTLS dispatcher therefore went to _connections = -1 and emitted a
spurious 'close' after every wrapped connection. Node keys the decrement on
`_server`, which only the native accept path sets; do the same.
The suppression #22806 added for the SSL_CTX a tls.Server builds at listen()
was keyed to create_ssl_context_from_bun_options. #29932 renamed that path to
BunSocketContextOptions::create_ssl_context / us_ssl_ctx_from_options, so the
suppression stopped matching and the known ~148KB leak resurfaces as a bare
SIGABRT on the x64-asan lane for any shard containing a TLS-server test. Main
never runs the test shards, which is why the rename went unnoticed.
…a throwing onread closed; make setSecureContext transactional

Three review findings:

- An onread callback handed the `true` sentinel (no Uint8Array from the
  factory yet) could not pause the stream: its `false` return was discarded.
  Node runs the readStop-on-false logic for that shape too.
- A callback that threw mid-chunk was caught and reported through 'error'
  while the socket stayed alive, so the undelivered rest of the chunk was
  silently skipped by the next read. Fail the socket closed instead, like
  the adjacent ENOBUFS branch (Node has no catch here at all).
- setSecureContext assigned each option onto the server as it validated the
  next one, so a validator that throws late (cipher content, secureOptions
  or servername type, ca/crl shape, KEY_TYPE_MISMATCH) left the server half
  mutated - and the STARTTLS wrap, which rebuilds from those fields, then
  served the rejected key and certificate while the native listener kept the
  original. Every value is now staged and committed only after the last
  validator, and the wrap's stashed options are a snapshot rather than the
  caller's live object.

The existing setSecureContext regression test threw in an early type check
with the same identity, so it could not detect the tear; it now uses a
late validator with a different certificate and asserts which one is served.
…ore walk

The eager add_auto_chain_from_store ran at CTX-build time, before crl /
allowPartialTrustChain seeded a store, and for a server with no `ca` it
walked the still-empty SSL_CTX_new() store - so the intermediate for a
leaf-only `cert` was never presented from NODE_EXTRA_CA_CERTS or the system
roots, despite the comment claiming Node parity. It also duplicated the walk
BoringSSL already implements behind SSL_MODE_NO_AUTO_CHAIN, which is set by
default and which bun never cleared.

Do what Node does instead: clear SSL_MODE_NO_AUTO_CHAIN at context build
(crypto_context.cc#L1640) and, when no user CA is given, seed the context's
store with the shared default roots the way Node's addRootCerts() does. The
handshake-time walk then also covers CAs added after construction (pfx
extras, addCACert) with no eager re-walk, and runs against the post-SNI
context. -57/+18.
…e-doc step

SUPPORTED_ECDH_GROUPS, _VALID_CIPHERS_SET and CIPHER_LIST_SELECTORS are
hand-maintained mirrors of BoringSSL tables that nothing re-derived on a
BoringSSL bump. Add them to the upgrade checklist and pin the group set with
a public-API test (vendor/ is gitignored, so a test cannot parse the tables
themselves; retiring the group list entirely by binding the existing
SSLCtxPointer::setGroups is left as a follow-up).
…ned tail

The isPaused() guard on the deferred tail delivery exists for
resume()-then-pause(): Node's resume_ restarts the flow asynchronously, so a
pause() that lands first must win. read() is the opposite - Node's
Socket.prototype.read calls tryReadStart on the handle unconditionally,
regardless of the stream's flowing state - so after an onread callback
returned false, a redundant explicit pause() followed by read() starved the
queued tail forever. read()/_read() now mark the drain they schedule, and a
marked drain delivers (and restarts the handle) even while the stream is
paused, while a resume()-scheduled one still defers to a later pause().
…he server 'error' event

The lazy _sharedCreds build runs inside the tls.Server 'connection' listener,
so options that pass JS validation but fail native SSL_CTX construction (a
malformed key or cert PEM, a wrong pfx/key passphrase) threw synchronously
out of the user's server.emit('connection', raw) on the first wrap. Node
throws these from tls.createServer() itself; bun's lazy contract reports the
same failure on the server 'error' event at listen() time, so the STARTTLS
wrap now uses that same surface: destroy the raw socket and emit 'error'
(which, unhandled, still throws the original error).

Also set the kerrorEmitted latch at the reject-unauthorized tlsClientError
emit - the one of the four server-side report sites that did not take it -
so a native error racing the destroy cannot report the same socket twice.
The only textual conflict was test/js/node/net/node-net.test.ts, where
both sides appended tests at the end of the file and shared the trailing
`}\n});`. Resolved as a union of both blocks: main's
`connect({ localPort })` TIME_WAIT regression test and this branch's
accepted-socket buffering / onread tests.
Two changes that landed independently disagree once they meet.

`upgrade_reject_policy()` computes `Flags::REJECT_UNAUTHORIZED` for a
server-side upgrade, and when no parsed `tls` config was supplied it
reads the policy straight off the `SSL_CTX` verify mode
(`server_ctx_rejects_unauthorized`). Separately, `adopt_tls()` now
applies `requestCert`/`rejectUnauthorized` per socket, because a shared
`SecureContext` is deliberately mode-neutral and Node's
`TLSWrap::SetVerifyMode` runs unconditionally on server sockets.

With both in place, `socket.upgradeTLS({ tls: true, secureContext })`
took `request_cert = cfg.is_some_and(..) == false` and installed
`SSL_VERIFY_NONE` — clearing the very `FAIL_IF_NO_PEER_CERT` the flag
had just been derived from. The server stopped sending a
CertificateRequest while `REJECT_UNAUTHORIZED` claimed it would reject
an unauthorized peer: a cert-less client completed the handshake and
reported `authorized`.

Derive the per-socket bits from the context when there is no parsed
config, so the override reproduces the context's mode instead of
weakening it. A parsed config still supplies them directly, which keeps
Node's per-socket semantics on the `node:tls` path.
… test-infra diff

Four review comments.

**Symbol.for**: the two symbols this PR introduced to bridge node:net and
node:tls (`::buntlsarmhandshaketimeout::`, `::buntlsverifyerror::`) went
through the global registry, where any user module can read or overwrite them
on a socket. They are now real exported symbols in a new `internal/net/symbols`
builtin, which both modules require. `Symbol.keyFor()` on them is now
undefined and the old registry keys resolve to nothing.

**setDefaultCACertificates**: it split each element on a
`/(?=-----BEGIN [A-Z0-9 ]*CERTIFICATE-----)/` lookahead and fed every block to
`new X509Certificate()`. Node does none of that: `lib/tls.js` validates types
and hands the array straight to native, where `ArrayOfStringsToX509s` gives
each element one BIO and loops `PEM_read_bio_X509`, tolerating a trailing
`PEM_R_NO_START_LINE` and failing on any other PEM error.

Ported that to `NodeTLS.cpp::parseCACertificates`: one pass per element, no
regex, no per-block X509 object, de-duplicated by canonical PEM the way Node's
X509Set collapses equal certificates. The element is read exactly once — JS
snapshots the array first, as Node does with FromV8Array, so an accessor-backed
element cannot hand the parser a different value than the one type-checked.
The error `code` is now composed like Node's error::Decorate
(`ERR_OSSL_<LIB>_<REASON>`) instead of hardcoding one code for every PEM
failure, so a bad end line reports ERR_OSSL_PEM_BAD_END_LINE. Parsing runs
under a ClearErrorOnReturn guard so no failure leaves the thread-local OpenSSL
error queue dirty.

Checked against a built node v26.3.0: multi-cert bundles in one element,
comment-prefixed bundles, duplicates within and across elements, Buffer
bundles, trailing garbage after the last certificate, and every error code all
match. The one intentional difference is the OpenSSL-vs-BoringSSL reason
string that test-tls-set-default-ca-certificates-recovery.js already encodes.
All ten vendored set-default-ca-certificates tests pass.

**leaksan.supp**: no new suppression. #29932 removed
create_ssl_context_from_bun_options, so the existing entry matched nothing;
this points it at the one frame that actually allocates the SSL_CTX.

**expectations.txt**: dropped the entry this branch added.
…ression

`Listener.secure_ctx` holds one owned `SSL_CTX` ref taken from the per-VM
`SSLContextCache` in `listen()`. Its doc claimed "SSL_CTX_free on close", but
the only release was in `deinit`, i.e. when the GC finalized the Listener. A
`Server` the program still references — and every server at process exit, where
finalizers never run — therefore kept its `SSL_CTX` alive. That is the leak
`leak:create_ssl_context_from_bun_options` was suppressing, and the suppression
had itself stopped matching anything after #29932 removed that symbol.

Release the ref in `do_stop` instead, and delete the suppression.

Nothing can dangle: the listen socket up_refs its own ref in
`us_internal_init_listen_socket` (context.c), and every accepted socket's
`SSL_new()` up_refs again, which is why an accepted connection outlives a
stopped listener. `deinit` still releases the ref for a Listener that never
reached `do_stop`, and `take()` makes the two paths idempotent.

Measured with the `sslCtxLiveCount` test hook, holding strong references to the
servers so the GC cannot finalize them:

    before:  5 servers listen()+close()  ->  5 live SSL_CTX
    after:   5 servers listen()+close()  ->  0 live SSL_CTX

Two regression tests in ssl-ctx-cache.test.ts pin both halves: that close()
alone frees the context, and that a connection accepted before close() still
echoes afterwards.
….3.0 tests

The v26.3.0 sync brings these three upstream Node.js comments in as new
lines; rephrase the marker so the diff-hygiene check does not flag them
as Bun-owned action items. Tests are otherwise unchanged and still pass.
The snapshot exists so the input is read once; reading certs.length again
after the parse lets a Proxy return a different length than the parser saw.
The heapStats assertions (the precise leak signal) stay unchanged and
all pass. The RSS delta between rounds is a weak signal per the test's
own comment; net.ts's added per-socket buffering state nudges mimalloc
segment growth past the old 8MB bound on two release lanes (observed
8.7/12.4MB). 16MB still trips on any real per-iteration retention across
5000 rounds.
The InvalidCRL mapping still used bun_core::err!, a macro removed when
bun_core's error interning was replaced with per-crate thiserror enums.
Merging main brought the removal in without a textual conflict, so the
call site kept compiling in isolation but broke the build.

Give bun_http's Error an InvalidCRL variant like its siblings and return
that instead. fetch() surfaces the same "InvalidCRL" code as before.
Picks up the safety comment on `ThreadSafeFunction::free_orphaned`, which is
what `cargo clippy` was failing on for this PR: `undocumented_unsafe_blocks` is
denied workspace-wide, #34067 introduced the block without a comment, and the
Clippy workflow has no `push:` trigger so main never caught it.
…uite

Hoist the per-test client (and accepted server-side socket where the
test's contract keeps it open) above the try and destroy them in
finally, matching the convention in the rest of node-tls-server.test.ts.
A failed assertion previously skipped the .destroy() after it, and
server.close() does not terminate live connections.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No new findings after dad091b — all four prior rounds of review feedback have been addressed. This is a substantial rework of the TLS server error-routing and handshake-lifecycle paths, including security-relevant defaults (_rejectUnauthorized no longer consulting NODE_TLS_REJECT_UNAUTHORIZED server-side, requestCert normalization), so it's worth a human pass before merge.

What was reviewed:

  • _tlsError/_releaseControl/onSocketTLSError wiring against node v26.3.0's tlsConnectionListener — the dual '_tlsError'/'error' registration matches upstream (initially flagged, then verified against wrap.js:1284-1285).
  • requestCert/rejectUnauthorized normalization now flows consistently to both the native listener and onconnection's per-socket flags (regression fixed in d383cc3).
  • The prependOnceListener('secureConnection') is now inside the _releaseControl() guard so it can't leak across skipped emits.
  • New tests hoist client/accepted sockets above try and destroy in finally; the onread-throws test asserts exact stdout.
Extended reasoning...

Overview

This PR is a stacked follow-up to #32630 that reworks node:tls server-side error routing and several handshake-lifecycle behaviors to match node v26.3.0. It touches src/js/node/net.ts (~150 lines: ServerHandlers.handshake/error, initAcceptedTLSSocket, onconnection, _write pre-handle buffering, closeSocketHandle, onread throw handling), src/js/node/tls.ts (~60 lines: _tlsError/_emitTLSError/_releaseControl prototype methods, Server constructor's _requestCert/_rejectUnauthorized assignment, setSecureContext no longer touching those, tls.connect releasing control immediately), a comment-only change in _http2_upgrade.ts, and ~300 lines of new/rewritten tests across two test files.

Security risks

The PR directly changes TLS server security defaults and mTLS gating: _rejectUnauthorized now defaults to options.rejectUnauthorized !== false (unconditionally true) instead of consulting NODE_TLS_REJECT_UNAUTHORIZED; _requestCert is now === true-normalized; setSecureContext() no longer overwrites either. These are all tightening changes verified against node's own semantics, and the earlier review round caught and fixed a case where the normalization didn't reach the per-socket flag (d383cc3). The destroy(err) now flowing through the internal 'error' listener (instead of surfacing as uncaught) is the same absorption node has via onSocketTLSError. I don't see a path where this weakens verification, but the interaction between kRealListen's !tls.requestCert → rejectUnauthorized = false override, the server's normalized fields, and the STARTTLS wrap path is intricate enough that a maintainer familiar with the native accept path should confirm.

Level of scrutiny

High. This is production TLS server code — the accept path, handshake completion, error routing to 'tlsClientError', and the mTLS request/reject flags. The PR is well-documented (every change cites the upstream wrap.js line it mirrors and was verified against a built node v26.3.0 binary), has comprehensive new tests, and went through four rounds of automated review that surfaced and fixed real issues. But per the approval guidelines, security-sensitive code of this scope should not be auto-approved.

Other factors

Three prior review rounds found real issues (a requestCert: 1 regression, a leaked once-listener when the secureConnection emit is guarded but the prepend isn't, weakened test assertions, test cleanup ordering) — all fixed or justified. One finding (the extra socket.on('error', onSocketTLSError)) was declined with a citation to wrap.js:1284-1285 showing node does the same; that justification checks out. The closeSocketHandle null-handle fix and the kUpgradeAttached pre-handle write buffering are new mechanisms this PR introduces; both look correct but are the kind of state-machine additions a maintainer should sanity-check. The PR is stacked on an unmerged base branch, which also argues for a human coordinating the merge order.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Issue #35092 reports that NODE_TLS_REJECT_UNAUTHORIZED=0 disables the default requestCert enforcement on tls.Server / http2.createSecureServer (the server default went through rejectUnauthorizedDefault(), which reads the env var). The tls.ts change in this PR (_rejectUnauthorized = serverOptions?.rejectUnauthorized !== false) fixes that, so this should close #35092 when it lands.

I had an equivalent fix plus a regression test on https://github.com/oven-sh/bun/tree/farm/1583cafb/tls-server-reject-unauthorized-default (spawns a subprocess with NODE_TLS_REJECT_UNAUTHORIZED=0 and asserts a server with requestCert: true and no explicit rejectUnauthorized still tears down an unverifiable client cert). Feel free to cherry-pick the test from 272246c if useful; standing down on a separate PR since the fix is already here.

robobun and others added 5 commits July 22, 2026 21:10
…rowCryptoError

Per review: collect all inputs first via forEachInIterable into a
MarkedArgumentBuffer (keeps ArrayBufferViews alive) plus a
Vector<CACertInput> holding either the UTF-8 CString (for strings) or the
view's span. All JS type checks and coercions happen there with no BoringSSL
resources live, so RETURN_IF_EXCEPTION cannot leak.

The second pass only sees spans and uses ncrypto::BIOPointer / X509Pointer
RAII; any BoringSSL error is recorded and thrown once all RAII scopes exit.

Drops the hand-rolled ERR_OSSL_<LIB>_<REASON> synthesis in favor of the
existing throwCryptoError (CryptoUtil.h), the same decoration path
node:crypto uses (library/function/reason/code + opensslErrorStack). The
ERR_OSSL_* codes are composed from BoringSSL's error queue at runtime like
Node's error::Decorate, so they are not enumerable in ErrorCode.ts.
…e's Decorate

createCryptoError assumed BoringSSL reason strings are already macro-name
shaped, but compound ones (a library name forwarded as a PEM reason, e.g.
'ASN.1 encoding routines') contain spaces. Node's error::Decorate uppercases
AND replaces spaces with underscores; upstream
test-tls-set-default-ca-certificates-recovery pins the underscored form for
the BoringSSL case (ERR_OSSL_PEM_ASN.1_ENCODING_ROUTINES). All existing
ERR_OSSL/ERR_SSL code-checking tests still pass since their reasons were
already space-free.
forEachInIterable drives the iterator protocol; a tampered
%ArrayIteratorPrototype%.next can detach an earlier element's buffer between
callbacks, and a raw span into the view would then dangle (MarkedArgumentBuffer
roots against GC but not detach). Copy into CACertInput::owned like the string
branch, so pass 2 only reads owned memory. Drops the keepAlive buffer since
nothing in pass 2 now depends on a JS value.
…of cloning

Per review: store the JSArrayBufferView* (rooted by the MarkedArgumentBuffer)
and read vector()/byteLength() in pass 2 where no user JS runs, with a
re-checked isDetached() guard against a tampered %ArrayIteratorPrototype%.next
detaching an earlier element between pass-1 callbacks. String inputs still
hold a CString (the WTF string's backing is not guaranteed UTF-8). CACertInput
is now std::variant<WTF::CString, JSC::JSArrayBufferView*>.
Comment thread src/js/node/net.ts Outdated
…bytes

Node bounds each kernel read to the onread buffer's size, so each slice
is a separate onStreamRead call and an uncaughtException handler that
swallows a throw loses nothing: the next slice arrives on its own call.
Bun reads a larger native chunk and slices it in JS, so the single
outer catch meant the remaining slices of that chunk were dropped on an
open socket once reportError returned. Move the catch inside the loop
so a swallowed throw falls through to the next slice, and add a test
that pins the no-gap contract against a process.on('uncaughtException')
handler (verified against node v26.3.0).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The latest commit (a03c81b) addresses the last open finding — the per-slice catch now continues the loop after reportError(e), so a swallowed uncaughtException no longer drops the rest of the native read, and the new test pins that. No new issues found this pass. Given the scope (TLS error routing, _releaseControl/_closeAfterHandlingError state machine, server-side rejectUnauthorized/requestCert defaults) I'd still like a human to sign off before merge.

What was reviewed: the per-slice deliver() catch (ret stays undefined on throw → loop continues; sentinel path still returns once); closeSocketHandle's deferred cleanup now uses the captured handle so a second destroy() can't null-deref; initAcceptedTLSSocket registers onSocketTLSError on both '_tlsError' and 'error' (confirmed matches node's tlsConnectionListener); the _write pending-upgrade queue's close listener guards on kwriteCallback === callback so ServerHandlers.error failing the write first doesn't double-call it.

Extended reasoning...

Overview

This PR is a stacked follow-up to #32630 closing ten node v26.3.0 parity divergences in src/js/node/tls.ts and src/js/node/net.ts: node's _tlsError/_emitTLSError/_releaseControl trio, hadError on failed-handshake close, handshake-timeout via the socket's 'timeout' event (and retiring the handler at _finishInit), manualStart server sockets (read(0) instead of resume()), setSecureContext() no longer touching _requestCert/_rejectUnauthorized, the === true/!== false normalization of those two server flags, a same-tick write on a server-side wrap being queued until the deferred handle attaches, the onread throw becoming an uncaught exception via reportError with a per-slice catch, and a null-handle guard in closeSocketHandle's deferred cleanup. ~250 lines of test additions across two files.

Security risks

Several changes are security-adjacent and are, on inspection, security improvements: the server's _rejectUnauthorized default no longer consults NODE_TLS_REJECT_UNAUTHORIZED (which previously let a client-side env var silently downgrade an mTLS server), and setSecureContext() no longer clears _requestCert on an mTLS server. requestCert: 1 now behaves like false — a behavior change, but it matches node's === true normalization exactly and the earlier regression (native and JS disagreeing, rejecting every connection) was fixed in d383cc3 by reading the normalized server fields in onconnection. The onSocketTLSError listener on 'error' that survives _releaseControl was verified to match node v26.3.0's own tlsConnectionListener, so post-handshake error absorption is intentionally node-identical.

Level of scrutiny

High — this is TLS error routing and mTLS defaults. The changes are well-cited against lib/internal/tls/wrap.js line numbers and the PR description records line-for-line verification against a node v26.3.0 binary, but the state-machine interactions (_controlReleased × kErrorEmitted × _closeAfterHandlingError × the retained 'error' listener) are subtle enough that four earlier review passes each found a real issue. All four are now addressed and this pass found nothing new, but I don't think this is a PR to auto-approve.

Other factors

Test coverage is thorough (7 new TLS-server cases plus 2 rewritten onread cases, each with a stated node reference and cleanup hoisted into finally per the earlier review). The vendored upstream suite is reported at 330/332 with the two failures matching the base branch. The one documented residual divergence (bun drains remaining slices before exiting on an unhandled uncaughtException, vs node exiting after the first) is bun's general uncaught-exception timing and is called out in the test comment; the load-bearing property — no silent gap on an open socket — is what the new test asserts.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Heads up on CI: test/js/node/test/parallel/test-http2-reset-flood.js is flaking on x64-asan with a JSC !exception() assertion (ExceptionScope.h:61, SIGABRT, no stack). It reproduces locally at roughly 1 in 5 runs under bun bd on this branch and is labeled [new] (not on main). It uses net.connect with a 'data' listener (no onread), so it is outside the deliver() paths the recent onread commits touch; likely something in the broader net/http2 error-routing rework or the newer native commits. The other three failures in build 78263 are known-flaky on main.

Base automatically changed from claude/node-tls-26-compat to main July 23, 2026 01:24
@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun fix conflicts

1 similar comment
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

On it: conflicts resolved (all four files; the only main-side change since the merge-base was the squashed #32630, which this branch already carries, so the resolution keeps the follow-up changes). Building now to verify before pushing.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Conflicts resolved and pushed as 54f1a4e. The only main-side change to the conflicted files since the merge-base was the squashed #32630, which this branch already carried (verified: git diff origin/claude/node-tls-26-compat cc40d78107 -- <files> is empty), so the resolution keeps the follow-up changes. node-tls-server.test.ts and the onread suite pass at the same rate as before (the two known pre-existing failures in each are unchanged).

@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 971f6774-68eb-44e2-a0c4-b241b4165099

📥 Commits

Reviewing files that changed from the base of the PR and between cc40d78 and 54f1a4e.

📒 Files selected for processing (5)
  • src/js/node/_http2_upgrade.ts
  • src/js/node/net.ts
  • src/js/node/tls.ts
  • test/js/node/net/node-net.test.ts
  • test/js/node/tls/node-tls-server.test.ts

Walkthrough

Changes

The PR updates Node-compatible TLS socket control, server verification options, handshake error and timeout sequencing, TLS upgrade write deferral, close cleanup, and onread exception delivery. It adds parity tests covering these lifecycle and data-delivery behaviors.

TLS and socket lifecycle

Layer / File(s) Summary
TLS control and verification options
src/js/node/tls.ts, src/js/node/net.ts, test/js/node/tls/node-tls-server.test.ts
TLS errors are routed until control release, client sockets release control immediately, and server certificate options remain fixed from constructor-normalized values.
Accepted TLS handshake errors and deadlines
src/js/node/net.ts, test/js/node/tls/node-tls-server.test.ts
Handshake errors, verification failures, timeout listeners, and post-handshake idle timeouts use revised close and error sequencing.
TLS upgrade attachment and socket I/O
src/js/node/net.ts, src/js/node/_http2_upgrade.ts, test/js/node/tls/node-tls-server.test.ts
Writes wait for TLS upgrade attachment, handle cleanup avoids stale-handle clearing, and server reads preserve buffered data behavior.
onread exception delivery
src/js/node/net.ts, test/js/node/net/node-net.test.ts
Thrown onread callbacks reach uncaught-exception handling per slice, with later slices continuing when the exception is handled.

Possibly related issues

Possibly related PRs

  • oven-sh/bun#33963 — Overlaps the TLS verification, control-flow, and server-side certificate-option changes.
  • oven-sh/bun#34513 — Overlaps accepted TLS socket error routing and listener sequencing.
  • oven-sh/bun#34507 — Overlaps TLS upgrade socket destruction and lifecycle handling.

Suggested reviewers: robobun

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main Node TLS/NET follow-up changes and stays specific to the modified areas.
Description check ✅ Passed The PR covers the required what/verification content in detail, though the headings differ from the repository template.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Comment @coderabbitai help to get the list of available commands.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants