Skip to content

tls: report the handshake's own failure when an early write drives it - #33390

Merged
Jarred-Sumner merged 2 commits into
mainfrom
farm/4f281ed3/tls-early-write-handshake-error
Jul 6, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
farm/4f281ed3/tls-early-write-handshake-error

Conversation

@robobun

@robobun robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator

What does this PR do?

Makes a TLS handshake failure report its own reason when the caller wrote to the socket before 'secureConnect', instead of falling through to a certificate error.

Symptom. An impossible TLS version window (minVersion > maxVersion) is reported by https.request as a certificate problem, pointing at the cert instead of at the configuration that is actually broken. tls.connect with the same options already reports it correctly.

const options = { host, port, ca, servername: "localhost", minVersion: "TLSv1.3", maxVersion: "TLSv1.2" };

tls.connect(options);   // ERR_SSL_NO_SUPPORTED_VERSIONS_ENABLED  (correct)
https.request(options); // ERR_TLS_CERT_ALTNAME_INVALID:
                        //   Hostname/IP does not match certificate's altnames: Cert does not contain a DNS name

Node reports the configuration error on both (ERR_SSL_NO_PROTOCOLS_AVAILABLE, OpenSSL's spelling of the same reason). Misclassified like this, the failure sends people to rotate certificates that are fine, and any retry/allowlist logic keyed on the error code takes the wrong branch.

Cause. SSL_write drives the handshake while it has not finished, so a caller that writes before 'secureConnect' discovers the handshake failure from inside us_internal_ssl_write. The https client is exactly such a caller: it flushes the request headers as soon as the socket connects, which is the whole difference from the plain tls.connect repro above.

That write path dropped the reason:

} else if (err == SSL_ERROR_SSL || err == SSL_ERROR_SYSCALL) {
  ERR_clear_error();          // the reason is gone
  s->ssl_fatal_error = 1;     // and is_shut_down() is now true
}

ssl_fatal_error makes us_internal_ssl_is_shut_down() true, so the ssl_update_handshake() that follows never reaches SSL_do_handshake and dispatches ssl_trigger_handshake(s, 0) with nothing parked. ssl_dispatch_parked_reason() finds no reason and falls back to the X509 verify verdict, which is empty. net.ts then sees handshake(success=false, verifyError=null) — the shape of a native hostname-verdict failure — treats the dead session as established, and runs checkServerIdentity() against an empty peer certificate, producing ERR_TLS_CERT_ALTNAME_INVALID.

Visible directly at the native dispatch, with and without an early write:

no-write:      handshake success=false verifyError={ code: "EPROTO", message: "...NO_SUPPORTED_VERSIONS_ENABLED" }
write-on-open: handshake success=false verifyError=null

The handshake path and the read path already park the reason before clearing the queue; only the write path did not.

Fix. Extract that parking into ssl_park_fatal_reason() (handshake, read and write paths now share it) and call it from us_internal_ssl_write, so the handshake dispatch that the fatal write triggers reports EPROTO plus the OpenSSL reason. net.ts already routes that through tlsHandshakeError() into ERR_SSL_<REASON>.

The helper keeps the existing "only park while the handshake is unfinished" guard: that dispatch is the only consumer, so a reason captured after the handshake completed would linger and be misreported as another socket's failure. For the same reason, a parked reason still owned by a socket being detached is now cleared, mirroring the spill-slot release right above it. The full ownership invariant is written out in a comment below.

This is the us_socket_t path. #32929 fixes a different source of the same verifyError = null fall-through on the Duplex/SSLWrapper path; the two are complementary.

How did you verify your code works?

test/js/node/tls/node-tls-connect.test.ts gains two tests — the mechanism (tls.connect + a write before 'secureConnect') and the reported symptom (https.request), the latter also asserting that a satisfiable window over the same cert/CA/servername still returns a response, so the version range is provably the only thing that failed.

Both fail on the unfixed build with ERR_TLS_CERT_ALTNAME_INVALID and pass with the fix:

$ USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-connect.test.ts
  Expected: "ERR_SSL_NO_SUPPORTED_VERSIONS_ENABLED"
  Received: "ERR_TLS_CERT_ALTNAME_INVALID"

The rest of the file (15 pass), test/js/node/tls/, test/js/node/http/, test/js/bun/net/ and the fetch/serve TLS suites show no delta against the same build without the patch. Node's test-tls-min-max-version, test-tls-handshake-error, test-tls-destroy-whilst-write, test-tls-close-error, test-tls-close-event-after-write and test-tls-server-failed-handshake-emits-clienterror pass.

SSL_write drives the handshake while it is unfinished, so a caller that
writes before 'secureConnect' - every https.request, which flushes its
headers as soon as the socket connects - hits the fatal handshake error
from inside us_internal_ssl_write. That path drained the OpenSSL error
queue without parking the reason, leaving the handshake dispatch with
nothing to report: node:tls saw a null verifyError, treated the dead
session as established, and fell through to checkServerIdentity against
an empty peer certificate.

Park the reason there the way the handshake and read paths already do,
extracting the shared helper, and drop a reason still owned by a socket
being detached.
@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Refactors OpenSSL fatal error handling in bun-usockets by extracting a shared ssl_park_fatal_reason helper used across handshake, read, and write error paths, and clears stale parked errors on socket detach. Adds TLS tests verifying version-negotiation error propagation via tls.connect and https.request.

Changes

SSL Fatal Reason Parking Refactor

Layer / File(s) Summary
ssl_park_fatal_reason helper and call sites
packages/bun-usockets/src/crypto/openssl.c
New ssl_park_fatal_reason helper centralizes OpenSSL error-queue reading, conditional copying into loop scratch, owner tagging, error-queue clearing, and marking ssl_fatal_error; replaces duplicated inline logic in ssl_update_handshake, SSL_read, and SSL_write failure paths; us_internal_ssl_detach now clears loop-level parked fatal error state owned by the detaching socket.
TLS version-negotiation error tests
test/js/node/tls/node-tls-connect.test.ts
Adds tests asserting ERR_SSL_NO_SUPPORTED_VERSIONS_ENABLED is surfaced for pre-secureConnect writes and for https.request with incompatible min/max TLS version ranges, plus a success case with a compatible range.

Sequence Diagram(s)

sequenceDiagram
  participant ComponentA
  participant ComponentB
  ComponentA->>ComponentB: observable interaction
Loading

Related Issues: None found.

Related PRs: None found.

Suggested labels: bun-usockets, tls, tests

Suggested reviewers: None found.

🥕 A rabbit hops through sockets deep,
Parking fatal errors it will keep,
One helper now to rule them all,
Handshake, read, and write's own call,
While TLS tests confirm the versions right —
Hop, hop, secure the connect at night.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the early-write TLS handshake failure fix.
Description check ✅ Passed The description matches the required template and includes both purpose and verification details.

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

@github-actions github-actions Bot added the claude label Jul 5, 2026
@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:15 PM PT - Jul 5th, 2026

❌ @robobun, your commit 72dcc8d has some failures in Build #68662 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 33390

That installs a local version of the PR into your bun-33390 executable, so you can run:

bun-33390 --bun

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 5, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

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

I didn't find any issues, but this touches the native TLS handshake error path and shared per-loop fatal-error state in openssl.c, so it's worth a human look.

Extended reasoning...

Overview

This PR fixes error misclassification in Bun's TLS layer: when a caller writes to a TLS socket before secureConnect (as https.request does), a handshake failure discovered inside SSL_write was having its OpenSSL reason dropped via ERR_clear_error(), causing the subsequent handshake dispatch to fall through to an empty X509 verdict and ultimately surface as ERR_TLS_CERT_ALTNAME_INVALID instead of the real ERR_SSL_NO_SUPPORTED_VERSIONS_ENABLED.

The fix extracts the existing park-reason logic (previously duplicated between the handshake and read paths) into ssl_park_fatal_reason() and calls it from the write path as well. It also adds cleanup of the per-loop parked reason in us_internal_ssl_detach() so a new socket reusing the same address can't inherit a stale reason. Two new tests in node-tls-connect.test.ts cover both the mechanism (early write on tls.connect) and the reported symptom (https.request).

Security risks

The change is in packages/bun-usockets/src/crypto/openssl.c — the TLS handshake state machine. It only affects which error string is reported when the handshake has already fatally failed; it does not change whether a handshake succeeds, how certificates are verified, or what data flows. The parked-reason slot is per-loop shared state keyed by socket pointer, and the PR's detach cleanup and the helper's HANDSHAKE_COMPLETED guard are specifically about preventing cross-socket misattribution. I don't see a way this weakens security, but the shared-state ownership model here is subtle enough that a reviewer familiar with this file should confirm.

Level of scrutiny

TLS handshake error handling in native C, with per-loop shared mutable state (ssl_last_fatal_error / ssl_last_fatal_error_owner) that multiple sockets on the same loop touch. The extraction is behavior-preserving for the two existing call sites (the ssl_update_handshake site already guarantees HANDSHAKE_PENDING at line 1677 before reaching the park, so the added != HANDSHAKE_COMPLETED guard is a no-op there; the read path already had the guard). The write-path addition is the actual fix. This deserves human review given the security-sensitive area and the cross-socket state involved.

Other factors

The PR description is thorough, the root cause is well-explained, and verification covers the relevant node parallel tests plus Bun's own TLS/HTTP suites. The bug-hunting system found no issues. No CODEOWNERS entry covers this path. CI (#68637) was still building at review time.

@robobun

robobun commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Writing out the parked-reason ownership model, since that's the part worth a careful read.

The invariant: a parked reason is consumed or cleared before its socket can be reused, and is never visible to another socket.

  1. Nothing parks after the dispatch. ssl_park_fatal_reason() only parks while ssl_handshake_state != HANDSHAKE_COMPLETED, and ssl_trigger_handshake() sets HANDSHAKE_COMPLETED as its first statement. So once a socket's handshake verdict has been dispatched, no later fatal write/read on it can leave a reason behind.

  2. Every park forces a dispatch that consumes it. Parking also sets ssl_fatal_error = 1, which makes us_internal_ssl_is_shut_down() true, which makes ssl_update_handshake() take the ssl_trigger_handshake(s, 0) branch. us_internal_ssl_close() calls ssl_update_handshake() before it can raw-close, so any close of a socket that parked goes through the dispatch. ssl_dispatch_parked_reason() copies the reason to the stack and zeroes both the string and the owner before it runs JS.

  3. The one route that skips us_internal_ssl_close() is a raw teardown (us_internal_socket_close_raw() on an error/RST path), which still reaches us_internal_ssl_detach(). That is where the new clear lives, next to the ssl_release_spill() that is there for exactly the same "a reused socket address would pick this up" reason.

  4. Consumption is owner-gated on the socket pointer, and us_internal_ssl_socket_relocated() already re-points the owner when a socket moves, alongside the spill owner.

On the extraction being behavior-preserving: at the ssl_update_handshake() site the new HANDSHAKE_COMPLETED guard is a no-op, because that function early-returns unless the state is HANDSHAKE_PENDING. The read path already had the guard verbatim. us_internal_ssl_write() is the only site whose behavior changes.


CI (build #68637, still running): one failed job, debian 13 x64-asan, on test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js — SIGABRT on ASSERTION FAILED: !scope.exception() || !hasSlot in JSValue::get. That test is a worker_threads terminate-while-transferring-a-MessagePort race; it opens no socket and never enters TLS, so this diff can't reach it. It passes 6/6 locally on an ASAN debug build of the base commit. The other annotation (node-http-connect.test.ts on Windows x64-baseline) is marked flaky and passed on retry.

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

I didn't find any issues — the extraction is behavior-preserving at the two existing sites and the detach cleanup mirrors ssl_release_spill — but this touches native TLS handshake error-handling with per-loop shared state, so it's worth a human look at the parked-reason ownership invariants.

Extended reasoning...

Overview

This PR fixes misclassified TLS handshake errors when a write is issued before secureConnect (the https.request path). It touches packages/bun-usockets/src/crypto/openssl.c:

  • Extracts the duplicated "park the OpenSSL fatal reason" block into ssl_park_fatal_reason() and calls it from the handshake, read, and (newly) write paths.
  • Clears a still-parked reason in us_internal_ssl_detach() so a socket reusing the same address can't inherit it, mirroring the adjacent ssl_release_spill() pattern.
  • Adds two hermetic tests in node-tls-connect.test.ts (local server, port 0) covering both the mechanism (tls.connect + early write) and the user-facing symptom (https.request with an impossible version window).

Security risks

The change affects which error is reported when the handshake has already failed — it does not touch verification logic, and ssl_fatal_error = 1 was already being set at the write site before this change. I don't see a way for this to weaken TLS validation. The main correctness risk is the per-loop shared ssl_last_fatal_error / ssl_last_fatal_error_owner state: a reason parked by one socket must never be reported for another. The helper's HANDSHAKE_COMPLETED guard, the owner-gated dispatch (which zeroes the slot before running JS), the relocation hook, and the new detach clear all cooperate to maintain that — the author's four-point invariant writeup in the PR thread matches what I read in the code.

Level of scrutiny

Native C in the TLS handshake path with loop-wide shared state and subtle ownership invariants across sockets warrants human review even for a small, well-reasoned diff. The refactor itself is behavior-preserving (the added HANDSHAKE_COMPLETED guard is a no-op at the ssl_update_handshake site since that function only runs while HANDSHAKE_PENDING, and the read path already had the guard verbatim), so the only real behavioral delta is at us_internal_ssl_write plus the defensive detach clear.

Other factors

The bug-hunting system found nothing. Tests follow repo conventions (await using, once(), exact error-code assertions, positive control that a satisfiable version window still succeeds). CI on the earlier build had one unrelated worker_threads ASAN failure and a Windows flake that passed on retry; the current build (#68662) is still running.

@robobun

robobun commented Jul 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status

Both builds are in, and nothing red has touched this diff. I'm not going to keep re-triggering, so here is the picture for whoever picks this up.

Build #68662 (re-run, complete): 284 of 287 jobs passed, and not a single test failed. The two red jobs are both darwin 26 aarch64 - test-bun, and both died on CI infrastructure before a test could run:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download (would silently fall back to the wrong binary).

The build carries no test-failure annotation at all. The one flaky annotation lists test/cli/hot/hot.test.ts (windows x64) and test/js/node/zlib/leak.test.ts (windows aarch64); both passed on the automatic retry. The remaining broken job is the dependency-skipped step that was waiting on the darwin artifact.

Build #68637 (first run, 283 jobs passed). Every failure was on a lane this change cannot reach — and every one of them passed on the re-run above, which is the clearest evidence they were flake:

failure lane relation to this diff
test-worker-message-port-transfer-terminate.js — SIGABRT, ASSERTION FAILED: !scope.exception() || !hasSlot debian x64-asan worker terminate race; the test opens no socket
bun-serve-file, fetch-file-upload (sendfile), proxy-stress-concurrent — timeouts darwin aarch64 plain HTTP; the same agent timed out on three unrelated files
terminal.test.ts — 90s timeout darwin x64 PTY spawn, no sockets
net-mongodb-pattern-leak — RSS 10.7 MB vs an 8 MB bound darwin x64 plain TCP; this diff allocates nothing
init.test.ts — 5 fail darwin aarch64 bun init + npm installs
node-http-connect.test.ts windows already annotated flaky, passed on retry

The change is ~40 lines confined to the SSL paths of openssl.c: it only runs for a us_socket_t with s->ssl != NULL, and it only changes which string is reported for a handshake that has already fatally failed. Across both builds not one TLS test failed — test/js/node/tls/, the node test-tls-* compat files, fetch.tls, tls-keepalive and the two new tests are green on every lane that got far enough to run them.

Locally, each suite above was run against a build of the base commit and against the same build with the patch applied; the failure sets were identical (test/js/bun/net/, test/js/node/http/) or a strict subset (test/js/node/tls/). Happy to rebase or re-run if a maintainer wants a fresh build.

@Jarred-Sumner
Jarred-Sumner merged commit 2496f2e into main Jul 6, 2026
76 of 78 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/4f281ed3/tls-early-write-handshake-error branch July 6, 2026 05:59
Jarred-Sumner pushed a commit that referenced this pull request Jul 23, 2026
…faces (+24 tests, 155→179 of 221 upstream passing) (#32630)

## What this does

Syncs Bun's vendored `node:tls` test suite to Node v26.3.0 and fixes the
compatibility gaps the upstream suite surfaced.

**Tests added: 24 upstream `node:tls` test files newly vendored (all
passing), 34 existing vendored files updated to their verbatim v26.3.0
content, plus 29 new Bun-side regression tests for the review fixes.**

Coverage of Node v26.3.0's own `test-tls*` suite, before → after:

| upstream suite (Node v26.3.0) | upstream files | before (main) | after
(this PR) |
| --- | --- | --- | --- |
| `test/parallel/test-tls*` | 217 | 151 vendored &amp; passing
(**69.6%**) | 175 (**80.6%**) |
| `test/sequential/test-tls*` | 4 | 4 (**100%**) | 4 (**100%**) |
| **total** | **221** | **155 (70.1%)** | **179 (81.0%)** |

(The 4 sequential files were already all vendored; they are updated to
their v26.3.0 content here. ~13 of the missing parallel files
structurally require `--expose-internals` and can never pass unmodified,
so 179/221 is ~85% of the reachable maximum.)

## Runtime fixes

- **net**: accepted sockets are no longer force-resumed into flowing
mode, so bytes that arrive before a `'data'` listener (or before a
delayed `new tls.TLSSocket(socket, { isServer: true })` wrap) are
buffered like Node instead of being emitted with no listener; the EOF
path now triggers `'end'` itself (`push(null)` + `read(0)`, like Node's
`onStreamRead`) so the `allowHalfOpen: false` write-side teardown still
runs without a consumer; sockets whose readable side was never engaged
are torn down at EOF so abandoned connections don't hold the event loop;
`onread.callback` receives the user's buffer (or factory result) with
the chunk copied in, sliced to its length; a factory that has not yet
produced a `Uint8Array` gets Node's literal `true` (and its `false`
return still pauses); a zero-length `onread` buffer destroys the socket
with `read ENOBUFS` (libuv's "user can't handle the read" contract)
instead of looping; and a callback that throws mid-chunk fails the
socket closed instead of leaving a silent gap.
- **rejectUnauthorized**: only a literal `false` disables peer
verification, matching Node's `options.rejectUnauthorized !== false` —
`null`/`0`/`''` now keep verification on (previously `null` silently
disabled it), and non-boolean values no longer throw
`ERR_INVALID_ARG_TYPE` out of the native option converter (Node never
type-checks the option).
- **tls.Server**: plain sockets handed in via `server.emit('connection',
socket)` are upgraded (STARTTLS pattern); `pauseOnConnect` no longer
stalls the handshake and is observable from the `'secureConnection'`
listener; `honorCipherOrder`/`secureOptions` now reach the SSL context;
`allowPartialTrustChain` is forwarded to the native context; `key`
entries given as `{ pem, passphrase }` and `sessionTimeout: null` are
normalized on the server path the same way `createSecureContext()`
normalizes them. (Note: Node v26.3.0's own `tls.Server` currently
*drops* `allowPartialTrustChain` — `Server.setSecureContext`'s option
whitelist in `lib/internal/tls/wrap.js` predates the option — so Bun
follows Node's documented contract here, verified against a built
v26.3.0 binary; everything else in this PR was verified to match the
binary's behavior exactly.)
- **TLSSocket**: server-side wraps adopt the fd one tick after
construction so handshake failures driven by already-buffered bytes
reach listeners attached right after the constructor, and a standalone
wrap that fails its handshake emits `'error'`; a bare `new
tls.TLSSocket()` only rejects unauthorized peers when asked (Node's
`_init` semantics) while `tls.connect()` keeps the secure default;
`getCertificate()` returns `{}` with no local cert;
`setMaxSendFragment()` validates its argument; `authorizationError` is
`null` after a clean client-cert verification; `socket.ssl` provides
`verifyError()` and `fd` like Node's TLSWrap.
- **tls.connect()**: defaults `ciphers` to `tls.DEFAULT_CIPHERS` so a
runtime assignment to `tls.DEFAULT_CIPHERS` is observed by the next
connect that omits the option.
- **SecureContext options**: implements `crl` (`PEM_read_bio_X509_CRL` →
`X509_STORE_add_crl` + `X509_V_FLAG_CRL_CHECK`, matching Node's
`SecureContext::AddCRL`), `sessionTimeout` (`SSL_CTX_set_timeout`),
`allowPartialTrustChain` (`X509_V_FLAG_PARTIAL_CHAIN`), and `sigalgs`
(`SSL_CTX_set1_sigalgs_list`). All four flow through the full SSLConfig
stack and participate in the SSL context cache key. A context still
sharing the process-wide default root store gets its own copy before any
of these mutate it (the same copy-on-write as Node's
`SecureContext::GetCertStoreOwnedByThisSecureContext`), so a
`crl`/`allowPartialTrustChain` context can never poison default-CA
verification for the rest of the process. CRL material is also counted
in `SecureContext.memoryCost`.
- **Client rejection order**: a `rejectUnauthorized: true` client is
destroyed with the verification error before `secureConnect` fires,
matching Node's `onConnectSecure`
(https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L1674-L1687).
Exactly like Node, the native handshake itself is never aborted by the
verify callback — Node's clients use `SSL_VERIFY_NONE` and "reject in
javascript"
(https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1236-L1244,
#L84-L95). An earlier revision's native `setVerifyStrict()` was removed
after review: it was inert (BoringSSL's `SSL_set_verify` keeps the
previously-installed callback when handed NULL), and had it worked it
would have diverged from Node.
- **Certificate chain**: BoringSSL's handshake-time auto-chain is
enabled the way Node enables OpenSSL's
(`SSL_CTX_clear_mode(SSL_MODE_NO_AUTO_CHAIN)`), and a context with no
user `ca` gets the shared default root store like Node's
`addRootCerts()`. A leaf-only `cert` therefore presents the intermediate
found via `ca`, `caFile`, `pfx` extras, `addCACert`,
`NODE_EXTRA_CA_CERTS`, or the system roots — and only the leaf's actual
issuer path is sent; `ca` entries unrelated to it are never presented.
- **Cipher handling**: cipher-string validation mirrors
`SSL_CTX_set_cipher_list` (only fails when nothing can match) and is
grounded in BoringSSL's cipher table; TLS 1.3 suite names are stripped
before reaching the native cipher list; `ecdhCurve` strings BoringSSL
cannot set are rejected like Node.
- **OpenSSL layer**: handshake failures report the oldest (root)
error-queue entry like Node; a missing key passphrase now fails with
`BAD_DECRYPT` instead of `BAD_PASSWORD_READ`.
- **setDefaultCACertificates()**: matches Node's error contract (skips
non-PEM elements, `ERR_CRYPTO_OPERATION_FAILED` when nothing parses,
BoringSSL's PEM error code for undecodable blocks).
- **CLI**: registers `--tls-min-v*`/`--tls-max-v*` and refuses
`--tls-min-v1.3` with `--tls-max-v1.2` at parse time, matching Node's
`node_options.cc` check.
- **opensslCli detection**: reports `false` (not `null`) when no usable
OpenSSL CLI is present, and treats LibreSSL as unusable since the
upstream tests assume the OpenSSL CLI's option syntax.

## Test changes

- 24 upstream tls tests newly vendored, 34 existing vendored tls tests
updated to their verbatim v26.3.0 versions (plus `common/crypto.js`).
- 8 tests keep the copies main already runs because their v26 versions
need features Bun does not implement yet (OCSP stapling, server
session-cache callbacks, `openssl s_client` interop that requires
close_notify on `end()`, SSL record-error surfacing).
- 29 new Bun-side regression tests in
`test/js/node/tls/node-tls-{server,connect,context}.test.ts`,
`test/js/node/tls/fetch-tls-cert.test.ts`,
`test/js/node/tls/ssl-ctx-cache.test.ts` and
`test/js/node/net/node-net.test.ts` covering: the zero-length `onread`
buffer and the paused-`onread` tail redelivery, `rejectUnauthorized:
null/0/''/false` (with a verifiable-client control), server-side
`allowPartialTrustChain` (boolean and truthy non-boolean), `key: [{ pem
}]`/`[{ pem, passphrase }]` and `sessionTimeout: null` on
`tls.createServer`, the presented certificate chain excluding unrelated
`ca` entries (inline `ca` and `certFile`/`caFile`), mTLS over a STARTTLS
wrap (via `tls.Server` and via a bare `new TLSSocket(socket, { isServer:
true })`), `TLSSocket._requestCert` defaults (Node's `_init` table), a
throwing `setSecureContext()` not corrupting the STARTTLS credentials,
`fetch()` surfacing `InvalidCRL` and honoring a lone `tls.sigalgs`, the
assignable `socket.ssl` property, and a subprocess proving a `crl`
server cannot poison the process-wide default CA store.

## Notes for reviewers

**Known red check: `robobun/evidence` ("diff hygiene check found
TODO/FIXME markers in added lines").** The three flagged markers are
upstream Node comments inside test files this PR vendors verbatim from
v26.3.0 (`test-tls-client-getephemeralkeyinfo.js`,
`test-tls-env-bad-extra-ca.js`, `test-tls-ticket.js`); they are Node's
own action items, not Bun's. 32 vendored test files already in-tree
carry the same markers — they simply are not "added lines" in their PRs'
diffs. Rewording them was tried and reverted: vendored upstream files
have to stay byte-identical to be diffable against Node. Needs a gate
override or a vendored-path exclusion.

All review comments are addressed (the later rounds land as individual
commits on top so they can be re-reviewed in isolation):

- zero-length `onread` buffer: fixed as `read ENOBUFS` + destroy, which
is what Node/libuv actually do (Node performs no up-front length
validation, so the suggested `ERR_INVALID_ARG_VALUE` would itself be a
divergence).
- shared default CA store mutated by `crl`/`allowPartialTrustChain`:
fixed with the same clone-on-write `us_ssl_ctx_add_ca_cert` already
used, extracted into a shared helper.
- presented chain built from every `ca` entry: replaced with an
`X509_verify_cert` issuer-path walk.
- `allowPartialTrustChain` dropped on the `tls.Server` path: fixed as
suggested, with one caveat the review (and I) initially missed — running
the scenario against a built Node v26.3.0 shows Node's `tls.Server`
*also* drops the option (its `setSecureContext` whitelist predates it),
so this makes Bun match Node's documented contract, not Node's literal
behavior.
- `{ pem, passphrase }` keys / `sessionTimeout: null` bypassing
normalization on the `tls.Server` path, `crl` missing from
`approx_cert_bytes()`, primordials in the new parsing paths: all fixed
as suggested (`setVerifyStrict`, whose arity was also flagged, was later
removed outright — see above).
- `rejectUnauthorized: null` (pre-existing divergence): fixed at its
real source in `net.ts`/`tls.connect()`/`tls.Server` rather than only at
the flagged line.
- From the later review rounds: a `ca` that parses to zero certificates
(e.g. a key PEM, tolerated like Node) is now treated as an
intentionally-empty pin set instead of being silently replaced with the
default roots — node fails such a connection closed and Bun previously
verified it against the default roots (a fail-open; regression-tested
via a child process whose default roots *can* verify the chain);
`socket.ssl.verifyError()` is populated for server-side sockets (Node's
own server path reads the same `TLSWrap.verifyError()`); a `tls.Server`
no longer emits `tlsClientError` for an unverifiable client it *accepts*
(`rejectUnauthorized: false`) — Node only surfaces the failure when
rejecting (on the rejecting path Bun's `tlsClientError` carries the
deterministic verify code where node surfaces an incidental `ECONNRESET`
from its silent destroy; left as the more useful shape); `onread` no
longer invokes the callback for data that races the pause (nothing is
delivered between a `false` return and `resume()`, per Node's `readStop`
contract); invalid `ecdhCurve` errors carry Node's `code:
'ERR_CRYPTO_OPERATION_FAILED'` without changing `String(err)`; and
`sigalgs` is validated on every secure context
(`createSecureContext`/`connect`/`addContext`), not only on
`tls.Server`.
- From the second-pass review: `setSecureContext` now stages every
option and commits after the last validator, so a call that throws late
cannot leave the STARTTLS wrap serving the rejected key/cert (and it
stashes a snapshot, not the caller's live object); an `onread` callback
holding the `true` sentinel can pause, and one that throws destroys the
socket instead of leaving a byte gap; the eager auto-chain walk was
replaced with BoringSSL's own handshake-time auto-chain plus Node's
default-root seeding (which also makes `NODE_EXTRA_CA_CERTS`
intermediates work and de-duplicates `addCACert`); the hand-mirrored
BoringSSL group/cipher tables are pinned by a test and called out in the
upgrade doc.
- From the follow-up review: a context given
`crl`/`allowPartialTrustChain` with no `ca` now seeds its own private
copy of the default roots and keeps it through socket attach, so those
options are honored exactly like Node instead of being silently dropped
(`tls.connect({ crl })` with no `ca` fails closed with
`UNABLE_TO_GET_CRL` in both runtimes — regression-tested); `fetch()`
maps an invalid `crl` through its own `InitError` arm; the `socket.ssl`
accessor accepts assignment like Node's plain property; and a `fetch()`
whose only TLS option is `sigalgs` now builds its own SSL context
instead of dropping the option (regression-tested via a deliberate
no-common-sigalgs handshake failure).
- The commit also carries autofix.ci's two-file docs formatting fixup
(`docs/guides/util/base64.mdx`, `docs/runtime/web-apis.mdx`): current
`main` has formatting drift in those files, so the bot re-adds that
fixup to every push of this branch; folding it in keeps the branch at a
single commit and leaves the bot nothing to do.

## Not covered yet (follow-ups)

### Blocked by BoringSSL public-API limitations

- **TLS-1.3 verify-data APIs** (`tlsSocket.getFinished()` /
`getPeerFinished()`): BoringSSL's
`SSL_get_finished`/`SSL_get_peer_finished` return 0 for TLS 1.3
(`vendor/boringssl/ssl/ssl_lib.cc:1558`), so the verify data those tests
assert has no public source. **Correction**: an earlier revision of this
section also claimed Bun's TLS-1.3 *server* completed its handshake one
flight early (before the client's Finished). Checking the vendored
source and probing both runtimes shows that is not the case —
BoringSSL's server handshake only returns early when 0-RTT early data is
accepted (`vendor/boringssl/ssl/tls13_server.cc#L1247`), which Bun does
not enable, `SSL_CB_HANDSHAKE_DONE` is a public completion signal
regardless, and node and Bun order `client secureConnect → server
secureConnection` identically over loopback.
- **close_notify on `end()`** (4+ tests): BoringSSL has no TLS
half-close — once a shutdown is initiated, further I/O on that `SSL`
fails with `SSL_R_PROTOCOL_IS_SHUTDOWN` (re-verified in the vendored
source). Bun therefore sends a TCP FIN instead of close_notify on
`.end()` so the documented write+end-then-read pattern keeps working
(see the comment in `us_internal_ssl_shutdown`); sending close_notify
there would require BoringSSL-level half-close support. `openssl
s_client` treats the missing close_notify as truncation.

### Known gap (follow-up): per-SNI contexts on the STARTTLS wrap path

- `server.addContext()` certificates — and even an explicit
`SNICallback` — are not consulted for sockets handed to a `tls.Server`
via `server.emit('connection', socket)`: the wrapped socket serves the
base context's certificate, while node serves the per-SNI one
(dual-runtime probe, both forms). The cause is native, not a missing
option forward: Bun's JS-SNI dispatch hangs off BoringSSL's
`select_certificate_cb`, which is `SSL_CTX`-level and registered on each
*listener's* own context, and an adopted (STARTTLS) socket runs on the
user's shared, deliberately callback-free `SecureContext` context — so
the ClientHello's `server_name` never reaches JS there. Honoring it
needs per-socket SNI dispatch for adopted sockets (BoringSSL's per-SSL
`SSL_set_cert_cb` plus the listener path's pause/resume), a
self-contained native follow-up. The normal `tls.Server` accept path
honors `addContext()` and is covered by the vendored SNI tests.

### Environment note (not a Bun behavior gap)

-
`test/js/node/test/parallel/test-tls-get-ca-certificates-node-use-system-ca.js`
only runs where system certificates exist (macOS) and is incompatible
with a color-forcing environment: it interpolates a child's `-p` stdout
into a second `-p` script, so `FORCE_COLOR` in the inherited env injects
ANSI escapes into that script and breaks it — **node v26.3.0 fails it
the same way under `FORCE_COLOR=1`**. Bun's behavior under test is
correct (`NODE_USE_SYSTEM_CA=1` and `--use-system-ca` produce the same
`tls.getCACertificates('default')`, verified against the node binary);
this PR's only change to the file is its trailing newline.

### Known gaps closed after review (both were first listed here as
follow-ups)

- ~~**STARTTLS + mTLS**~~ — **now fixed in this PR.** Two real causes,
both found by comparing against a built node v26.3.0: (1) a socket
adopted by `upgradeTLS` never applied `requestCert`/`rejectUnauthorized`
per socket (Node does it in `TLSWrap::SetVerifyMode`,
https://github.com/nodejs/node/blob/v26.3.0/src/crypto/crypto_tls.cc#L1225-L1234;
the shared `SecureContext` SSL_CTX is deliberately mode-neutral), and
(2) `getPeerCertificate()` / `getEphemeralKeyInfo()` decided "server or
client?" from the JS handlers' mode — which stays client-mode for
adopted sockets — instead of the SSL's own handshake side
(`SSL_is_server`), so an mTLS STARTTLS server reported `{}` for a client
certificate it had in fact requested and verified. Both
regression-tested (`tls.Server` STARTTLS wrap, and a direct `new
TLSSocket(socket, { isServer: true, secureContext, requestCert: true })`
whose secure context lacks `requestCert`); the dual-runtime probe now
matches node on both scenarios. One nuance kept as-is: Node computes
`socket.authorized` only for sockets owned by a `tls.Server`, so a
standalone server wrap keeps the `_init` default (`false`) even after
verifying the peer; Bun fills it in from the verify result (a strict
superset of information).
- ~~**`onread.callback()` returning `false` mid-chunk**~~ — **now fixed
in this PR.** Node stops the *handle* (`readStop`) and loses nothing:
its reads are bounded by the user buffer, so the bytes the callback has
not consumed wait in the kernel until `readStart`. Bun's emulation has
already read the whole native chunk, so it now keeps the undelivered
tail and redelivers it — never re-entrantly — when the socket resumes,
before the kernel flow restarts (and ahead of any data that raced the
pause). Regression-tested (a 12-byte burst through a 4-byte
`onread.buffer`, pausing after the first slice, still delivers
`abcd,efgh,ijkl`); the dual-runtime probe shows the identical slice
sequence in node v26.3.0.

### Substantial native features

- **TLS over generic duplex streams** (9 tests) — requires reworking the
`UpgradedDuplex.rs` TLS engine state machine.
- **Server session-cache/ticket callbacks**
(`newSession`/`resumeSession`, 2 tests) — requires async
`SSL_CTX_sess_set_new_cb`/`get_cb` bridging with handshake suspension.
- OCSP stapling, PSK server options, per-worker `--use-system-ca`,
native default-CA-store override for `fetch()`,
`https.Server.setSecureContext` (Bun's https uses `Bun.serve` directly
rather than `tls.Server`).

### Structural

- 13 upstream tests require `--expose-internals`/Node-internal modules
(`internal/test/binding`, `internal/net`, `internal/timers`, etc.) and
cannot pass unmodified.

### Prior art checked

Electron also runs Node.js on BoringSSL; their `patches/node` and
`patches/boringssl` were reviewed and contain no patches addressing the
TLS-1.3 handshake-completion timing, `SSL_get_peer_finished` for TLS
1.3, close_notify half-close, TLS-over-duplex, or session-cache callback
areas above — their BoringSSL-compat work is limited to cipher/algorithm
availability gating and test relaxation. The gaps documented here have
no existing third-party fix to borrow.

## How this was verified

- **Against a built Node v26.3.0 binary**: the same script (11 scenarios
over real sockets — the `rejectUnauthorized` matrix client- and
server-side, a zero-length `onread` buffer, the paused-`onread` tail
redelivery, the presented certificate chain with an unrelated `ca`
entry, the CRL/shared-store isolation in a `NODE_EXTRA_CA_CERTS` child
process, a `[{pem,passphrase}]` + `sessionTimeout: null` server, and the
two STARTTLS mTLS wraps) was run under `node v26.3.0` and under this
branch's `bun-debug`; **15 of 16 output lines are byte-identical** (down
to `read ENOBUFS`). The one intentional difference is the server-side
`allowPartialTrustChain` case described above, where Node's
implementation drops the documented option.
- All **330** vendored upstream `test-tls*`/`test-net*` files pass
against a debug build of this revision, and the four Bun-side
`node:tls`/`node:net` test files pass (135 tests, 13 of them new
regression tests for the review fixes).
- The two `rejectUnauthorized` fixes were additionally proven
non-vacuous by reverting just those lines and watching their 4
regression tests fail.
- Cross-target Rust compilation was verified on the previous revision
via `bun run rust:check-all`; the Rust delta since is one field added to
an existing accounting function. CI re-validates the full matrix.


---

**Rebased onto current `main`** (post-#33390/#33388). Two fixes this
branch carried were independently landed upstream in the meantime —
#33390 (a handshake driven by an early write reports its own failure;
`main`'s `ssl_park_fatal_reason()` helper is the same logic this branch
had inline, so the rebase adopts the helper) and #33388 (the
`upgradeTLS` open-handler UAF). Everything else is unchanged; `main`'s
newly-added tls/net tests pass against this branch's changes.

<!-- robobun:evidence:begin -->

---

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

<!-- robobun:evidence:end -->

---------

Co-authored-by: Alistair Smith <hi@alistair.sh>
Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
cirospaciari pushed a commit that referenced this pull request Aug 12, 2026
…write like the read and handshake drivers (#37675)

### What

`us_internal_ssl_write` was the one handshake-driving call in
`openssl.c` (`SSL_write` runs the handshake while it is pending) that
did not follow the `ssl_in_use` protocol `ssl_update_handshake` (around
`SSL_do_handshake`) and `us_internal_ssl_on_data` (around `SSL_read`)
use. This makes the write path consistent with them:

- A write issued while `ssl_in_use` is set, i.e. from a callback that
`SSL_read` / `SSL_do_handshake` on this same socket is currently
running, is parked instead of calling `SSL_write`: it returns 0 and sets
`ssl_write_wants_read`, exactly what a write issued before the handshake
already gets on the `WANT_READ` path. The caller buffers, and the
existing `ssl_retry_parked_write` dispatches writable once the handshake
completes.
- `SSL_write` itself now runs with `ssl_in_use` set and honors
`ssl_pending_detach` in its epilogue, the same shape as the other two
drivers (`SSL_write` drives the handshake while it is pending, so it is
a driver too).

### Observable before / after

The first point is reachable today. The server-side ALPN and SNI
selection callbacks run from inside the `SSL_read` that is processing
the ClientHello, and both hand the accepted socket to JS (`Bun.listen`'s
`alpnCallback` / `serverName` hooks, which `node:tls`'s `ALPNCallback` /
`SNICallback` are built on). A `socket.write()` there re-entered
BoringSSL on the SSL it was still working on and the handshake failed:

```ts
const server = tls.createServer({ key, cert, ALPNCallback({ protocols }) { conn.write("hi"); return protocols[0]; } });
server.on("connection", s => (conn = s));
// client: error:10000438:SSL routines:OPENSSL_internal:TLSV1_ALERT_INTERNAL_ERROR
// server: tlsClientError error:100000c2:SSL routines:OPENSSL_internal:PROTOCOL_IS_SHUTDOWN
```

With this change the handshake completes and the bytes are delivered
right after it, ahead of anything written from `secureConnection`. At
the `Bun.listen` level the write reports 0 and `drain` fires once the
handshake is done, which is the contract a pre-handshake write already
has. (`socket.end("bye")` from inside the callback goes through the same
path and now works as well.)

The second point has no reachable trigger at the moment:
`us_internal_ssl_write` zeroes the read-input window before calling
`SSL_write`, so the ClientHello-driven callbacks cannot fire from inside
it, the session and keylog callbacks are parked in ex_data and flushed
after the SSL call returns, and `us_socket_raw_write` (what the BIO
calls) never closes the socket. It is included so the write driver keeps
the same invariants as the other two if that ever changes; if the
deferred close did fire, the epilogue resets the batching state (so
leftover records cannot leak into the next socket's batch), runs the
close, and reports 0 like any write to a socket that is going away.

`us_internal_ssl_shutdown` is intentionally left alone. It is reachable
from the same callbacks (`socket.shutdown()` from inside the ALPN/SNI
selection), but BoringSSL's `SSL_shutdown` returns 1 without touching
any state while the handshake is in progress (`ssl_lib.cc`, "If we are
in the middle of a handshake, silently succeed"), so the only effect is
the TCP FIN and the connection the caller asked to shut down goes away;
verified under the ASAN build from both callbacks. There is nothing to
defer there, unlike a write, which has bytes to deliver after the
handshake.

### Why this is the right place

The guard has to live in `us_internal_ssl_write`: it is the only
function that knows whether a BoringSSL call on this `SSL*` is on the
stack, and every caller (`Bun.listen` sockets, `node:tls`, uWS) already
treats 0 as "buffer and wait for writable", so parking needs no changes
above it. Buffering in usockets or gating on the JS side would add a
second buffer for a case the existing parked-write machinery already
handles. It keys on `ssl_in_use` rather than on the handshake state
because the two are different conditions: before the handshake
completes, a write issued from outside any BoringSSL call is still meant
to reach `SSL_write` (the https client flushes its headers on connect
and that write drives the handshake; the tail of this function reports
the handshake failure for it, #33390), so only a write issued while a
BoringSSL call is actually on the stack is parked.

### Tests

- `test/js/node/tls/node-tls-server.test.ts`: `write()` from inside
`ALPNCallback` and from inside `SNICallback`; asserts the client
receives the callback's bytes followed by the `secureConnection` bytes,
the ALPN selection still applies, and no `tlsClientError` fires. Fails
on main with the errors quoted above.
- `test/js/bun/net/socket.test.ts`: the same through `Bun.listen`'s
`alpnCallback` and `serverName` hooks, asserting the exact sequence
`write:0` -> `handshake:true` -> `drain` -> `close` and that the data
written from `drain` arrives. On main the sequence is `write:0` ->
`handshake:false` -> `close`.

Both pass 15/15 reruns with the debug (ASAN) build. Also ran
`test/js/node/tls/`, `test/js/bun/net/socket.test.ts`,
`tcp-server.test.ts`, `test/js/web/fetch/fetch.tls.test.ts`,
`test/js/bun/http/bun-serve-ssl.test.ts` and
`test/js/node/http2/node-http2.test.js` locally; the only failures are
unrelated to this change (plain-TCP `localhost` listen/connect family
mismatch in this container, reported separately, and a few
subprocess-spawning tests that sit right at the 5s default timeout under
the debug build here and pass with a longer timeout).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants