Skip to content

node:tls: fix getCipher().version, getEphemeralKeyInfo(), client STARTTLS, and checkServerIdentity exception handling - #32824

Open
robobun wants to merge 6 commits into
mainfrom
farm/cd758895/node-tls-client-compat
Open

node:tls: fix getCipher().version, getEphemeralKeyInfo(), client STARTTLS, and checkServerIdentity exception handling#32824
robobun wants to merge 6 commits into
mainfrom
farm/cd758895/node-tls-client-compat

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes eight node:tls client divergences from Node, found by a differential probe run against a real Node v26 tls.Server (only the client side exercised Bun). The probe output is byte-identical to Node after this change, except client-initiated renegotiate() on TLS 1.2, which BoringSSL does not support.

getCipher().version is always "TLSv1/SSLv3"

client.getCipher()
// node: { name: "ECDHE-RSA-AES128-GCM-SHA256", ..., version: "TLSv1.2" }
// bun:  { name: "ECDHE-RSA-AES128-GCM-SHA256", ..., version: "TLSv1/SSLv3" }

BoringSSL hardcodes SSL_CIPHER_get_version() to that string. Node (OpenSSL) reports the cipher's minimum protocol version. Rebuilt the same strings from SSL_CIPHER_get_min_version(): TLS 1.3 and SHA-2 AEAD suites directly, and for the pre-1.2 suites SSL_CIPHER_get_kx_nid() splits the ECC ones ("TLSv1.0", defined for TLS 1.0 by RFC 4492) from the SSLv3-era rest, matching OpenSSL's table exactly.

getEphemeralKeyInfo() returns {} on a TLS 1.2 ECDHE connection

Node reports { type: "ECDH", name: "X25519", size: 253 }. The old code called SSL_get_privatekey(), which is the client's own key and is null for a client without a certificate, so the result was always empty (and leaked an EC_KEY on the one path that got further). Replaced with SSL_get_negotiated_group(). Node returns {} on TLS 1.3 and on a static-RSA key exchange; both are matched.

An exception from a user checkServerIdentity becomes a socket 'error'

Node does not guard the callback: a throw aborts onConnectSecure and reaches the process as an uncaught exception, and 'secureConnect' never fires. Bun's native handshake dispatcher routes any exception from the JS handshake handler into the socket's error handler, which downgraded the throw into socket.emit('error', err). The JS handler now catches only the user callback, abandons the handshake, and defers the rethrow out of the native try frame. The callback is also now skipped on a resumed session, matching Node's !this.isSessionReused() guard (found in review; Bun was calling it twice).

new tls.TLSSocket(socket, { isServer: false }) + _start() throws ERR_MISSING_ARGS

The client STARTTLS pattern. _start() called connect() with no port, path, or socket. It now hands the wrapped socket to connect(), the same thing tls.connect({ socket }) does. The constructor also honors its own rejectUnauthorized now: that path never reaches Socket.prototype.connect, which was the only place the option was read. A setServername() call between construction and _start() is now forwarded through to the handshake as the client SNI (found in review; Node sends it, Bun was wiping it).

getEphemeralKeyInfo() on a resumed TLS 1.2 session

Found in review. A resumed abbreviated handshake has no ServerKeyExchange, so Node's SSL_get_peer_tmp_key is empty and the three keys are undefined. BoringSSL serializes the original handshake's group_id into the session blob, so SSL_get_negotiated_group would surface it; gated on SSL_session_reused.

rejectUnauthorized: null bypassed certificate verification

Found in review, pre-existing on tls.connect(). Node only disables verification on an explicit false (CVE-2021-22939); Bun stored the raw option value and then checked it for truthiness, so null silently skipped rejection where Node rejects. Normalized to !== false at each client store site (the TLSSocket constructor, the three Socket.prototype.connect TLS paths, and server setSecureContext). The bindgen layer already rejects 0/""/1 with ERR_INVALID_ARG_TYPE, so null was the only non-boolean that reached these sites.

Deleted dead code

Made dead by the getEphemeralKeyInfo rewrite and removed in the same change: the SSL_get_privatekey, EVP_PKEY_id, EVP_PKEY_bits, EVP_PKEY_get1_EC_KEY, EC_KEY_get0_group, EC_GROUP_get_curve_name, and SSL_CIPHER_get_version FFI declarations, the EVP_PKEY/EC_KEY/EC_GROUP opaque handles, and the EVP_PKEY_DH/EVP_PKEY_X25519/EVP_PKEY_X448 constants.

Tests

Ten new tests in test/js/node/tls/node-tls-connect.test.ts; all ten fail on a build without the corresponding fix and pass with the fix. The existing getCipher assertions on "TLSv1/SSLv3" and the testEphemeralKeyInfoUnsupported helper in test/js/node/test/common/boringssl.js encoded the old behavior and are updated (the helper is renamed to testEphemeralKeyInfoEcdheOnly; the DHE parts of the original Node test still cannot run because BoringSSL has no DHE cipher suites).

The full test/js/node/tls/ directory passes with the change; the only failures are two that reproduce identically on main without it.

Not addressed, from the same probe, because BoringSSL has no client-initiated renegotiation: client.renegotiate() on a TLS 1.2 connection returns false where Node renegotiates.


[review] gate passed · iteration 7 · 6 files touched

fails on main (without fix)
ASAN without fix: 10 failed, 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-connect.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (1c65194cd)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.88ms]
(pass) should thow ECONNRESET if FIN is received before handshake [388.78ms]
(pass) should be able to grab the JSStreamSocket constructor [25.73ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [42.62ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [96.50ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when 
... (truncated)

release without fix: 1 failed, 18 skipped
bun test v1.4.0-canary.1 (74904474b)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.09ms]
(pass) should thow ECONNRESET if FIN is received before handshake [10.71ms]
(pass) should be able to grab the JSStreamSocket constructor [0.23ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [4.64ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [3.11ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port and host correctly
(skip) tls.connect > should process port, host, and callback correctly
(skip) tls.connect > should handle the absence of a callback gracefully
(skip) tls.connect > should timeout
(skip) tls.connect > should be able to transfer data
(skip) tls.connect using duplex proxy > should work with alpnProtocols
(pass) tls.connect using dupl
... (truncated)
passes on PR (with fix)
ASAN with fix: 18 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/node-tls-connect.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (1c65194cd)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.78ms]
(pass) should thow ECONNRESET if FIN is received before handshake [388.18ms]
(pass) should be able to grab the JSStreamSocket constructor [24.77ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [42.43ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [87.55ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when 
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 811ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen generated_host_exports.rs
generated_host_exports.rs: 90 exports (host=3, lazy=10, generic=77, rust=0); 254 extern-C blocks audited
[2/21] gen JS modules (bundle-modules)
Preprocess modules (6608ms)
Bundle modules (31ms)
Postprocesss modules (21ms)
Bundle Functions (595ms)
Generate Code (74ms)

[7.34s] Bundled "src/js" for production
  1889 kb
  161 internal modules
  12 native modules
  90 internal functions across 19 files
[2/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

  nightly
... (truncated)
diff hotspot
src/js/node/net.ts                                 |  62 +++-
 src/js/node/tls.ts                                 |  23 +-
 src/runtime/socket/tls_socket_functions.rs         | 146 ++++----
 test/js/node/test/common/boringssl.js              |  20 +-
 .../test-tls-client-getephemeralkeyinfo.js         |   2 +-
 test/js/node/tls/node-tls-connect.test.ts          | 412 ++++++++++++++++++++-
 6 files changed, 549 insertions(+), 116 deletions(-)

gate history · 4 passed · 0 rejected · iteration 7

evidence per changed file
file                                                      reads  edits  tests
src/js/node/net.ts                                           11      7     15
src/js/node/tls.ts                                            9      5     15
src/runtime/socket/tls_socket_functions.rs                   10     10     15
test/js/node/test/common/boringssl.js                         1      3     17
…de/test/parallel/test-tls-client-getephemeralkeyinfo.js      1      1     18
test/js/node/tls/node-tls-connect.test.ts                    13     18     15

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

An error occurred during the review process. Please try again later.

Walkthrough

TLS handshake handling now defers uncaught checkServerIdentity exceptions, normalizes rejectUnauthorized to booleans, supports STARTTLS-style socket startup, and derives cipher/version and ephemeral-key metadata from negotiated TLS state. The tests are updated to match the new behaviors.

Changes

TLS handshake, STARTTLS, and cipher/ephemeral key reporting

Layer / File(s) Summary
checkServerIdentity uncaught exception handling
src/js/node/net.ts
Adds runCheckServerIdentity with a sentinel symbol; on throw, defers rethrow via process.nextTick and returns the sentinel so both server and client handshake handlers skip further verification and return early.
rejectUnauthorized normalization
src/js/node/net.ts, src/js/node/tls.ts
Coerces explicit rejectUnauthorized values so only false disables verification, and updates both socket/connect paths and TLS constructors/servers to store the coerced boolean.
TLSSocket STARTTLS upgrade
src/js/node/tls.ts
_start reuses an already-stashed Duplex handle via connect({ socket: wrapped }) for client STARTTLS.
BoringSSL FFI surface for cipher version and ephemeral key info
src/runtime/socket/tls_socket_functions.rs
Removes EVP keytype/EC introspection constants and functions, adds TLS version and NID group constants plus SSL_get_negotiated_group, SSL_CIPHER_get_min_version, SSL_CIPHER_get_kx_nid FFI bindings.
get_cipher and get_ephemeral_key_info implementation
src/runtime/socket/tls_socket_functions.rs
get_cipher reconstructs protocol version strings from min-version/kx-nid; get_ephemeral_key_info derives ECDH group name/size from the negotiated group NID for non-reused TLS ≤1.2 sessions, replacing prior private-key-based derivation.
Tests for cipher version, ephemeral key info, STARTTLS, and checkServerIdentity throw
test/js/node/test/common/boringssl.js, test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js, test/js/node/tls/node-tls-connect.test.ts
Updates BoringSSL ephemeral key test to expect a concrete ECDH group; adds withTlsPair helper and new tests covering cipher version reporting, ephemeral key info across TLS 1.2/1.3 and session resumption, STARTTLS-style socket upgrades, rejectUnauthorized: false handling, and uncaught exception propagation from checkServerIdentity.
🚥 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 main node:tls behavior fixes introduced by the PR.
Description check ✅ Passed The description covers what changed and how it was verified, even though it doesn't use the repository's exact template headings.

Warning

Tools execution failed with the following error:

Failed to run tools: 13 INTERNAL: Received RST_STREAM with code 2 (Internal server error)


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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:32 AM PT - Jul 9th, 2026

@robobun, your commit 1c65194 has 5 failures in Build #71057 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32824

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

bun-32824 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. tls.connect({ socket }) fails to intercept stream, causing subsequent data to re-trigger cleartext listeners and throw "Invalid socket" #32239 - PR fix Support import assertions #4 directly addresses the STARTTLS pattern where tls.connect({ socket }) + _start() threw ERR_MISSING_ARGS because _start() didn't detect the wrapped socket on this._handle
  2. tls.rejectUnauthorized has no use #22870 - PR fix Support import assertions #4 makes rejectUnauthorized honored directly in the TLSSocket constructor, where it was previously ignored

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #32239
Fixes #22870

🤖 Generated with Claude Code

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Checked both before adding any Fixes line; neither is addressed by this PR, so I'm not linking them.

#32239 uses tls.connect({ socket }). That form goes straight through Socket.prototype.connect and never calls _start(), so it does not reach the new tls.TLSSocket(socket, { isServer: false }) + _start() change here (or any other changed line). The bug there is different: after the first upgrade the raw socket's cleartext 'data' listeners keep firing, so the repro attempts a second upgrade on the already-upgraded socket and hits the "Invalid socket" throw in the connect path. The issue's repro still reproduces as reported:

node: Attempting TLS Upgrade (Event execution #2)... SUCCESS
bun:  Attempting TLS Upgrade (Event execution #2)...
      Attempting TLS Upgrade (Event execution #3)...
      CRASHED ON EXECUTION #3 WITH: Invalid socket

#22870 is about rejectUnauthorized on a wss:// WebSocket client, which is Bun's native WebSocket implementation and never constructs a node:tls TLSSocket. tls.connect({ rejectUnauthorized }) was already honored before this PR through Socket.prototype.connect; the only behavior this PR changes is the bare new tls.TLSSocket(socket, options) constructor, which that report does not use.

Comment thread test/js/node/tls/node-tls-connect.test.ts Outdated
Comment thread test/js/node/tls/node-tls-connect.test.ts Outdated

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

Thanks for addressing both nits in b5b3ab7 — nothing further from me, but four separate behavior changes across the TLS handshake path (checkServerIdentity exception routing, rejectUnauthorized in the bare-constructor path, and the BoringSSL FFI rewrites) is more than I'm comfortable auto-approving without a human look.

Extended reasoning...

Overview

This PR fixes four node:tls Node-compat divergences across three runtime files plus tests:

  • src/runtime/socket/tls_socket_functions.rs: rewrites get_cipher() version reporting (replacing SSL_CIPHER_get_version with SSL_CIPHER_get_min_version + SSL_CIPHER_get_kx_nid) and get_ephemeral_key_info() (replacing the broken SSL_get_privatekey path with SSL_get_negotiated_group), plus removes ~7 now-dead FFI declarations.
  • src/js/node/net.ts: extracts runCheckServerIdentity() so a user-thrown exception in checkServerIdentity is rethrown via process.nextTick as an uncaught exception (matching Node) instead of being downgraded to a socket 'error' by the native handshake error router. Applied to both SocketHandlers and SocketHandlers2.
  • src/js/node/tls.ts: TLSSocket constructor now honors its own rejectUnauthorized option, and _start() now passes the wrapped socket to connect() for the client-STARTTLS pattern instead of throwing ERR_MISSING_ARGS.
  • Six new tests in node-tls-connect.test.ts, plus updates to the BoringSSL test helper to reflect that getEphemeralKeyInfo() now actually works.

Since my prior review, commit b5b3ab7 addressed both of my inline nits: the getEphemeralKeyInfo() empty-result assertions now use .toStrictEqual({type: undefined, name: undefined, size: undefined}), and the checkServerIdentity fixture test now includes stderr in the combined assertion object.

Security risks

This is squarely security-sensitive territory. Two changes in particular touch certificate-verification flow:

  1. The rejectUnauthorized constructor change affects whether a TLS client rejects an untrusted peer on the bare-new TLSSocket(socket, opts) path. The change looks correct (it only sets _rejectUnauthorized when the option is explicitly provided, preserving the env-var default otherwise), but any change to this flag's plumbing deserves a careful human read.
  2. The checkServerIdentity exception-routing change alters control flow during the post-handshake verify step: when the user callback throws, the handler now early-returns (abandoning the rest of the handshake handler — secureConnect never fires, authorized is never set) and rethrows on the next tick. This matches Node, but it's a behavior change in how identity-check failures surface.

The Rust FFI changes (getCipher().version, getEphemeralKeyInfo()) are read-only/informational and don't affect the security posture of the connection itself.

Level of scrutiny

High. node:tls handshake handling, certificate verification routing, and rejectUnauthorized are exactly the kind of code where a subtle mistake silently weakens security. The changes are well-reasoned, well-commented, and each has a dedicated test that fails on main, but per policy I don't auto-approve security-sensitive crypto/TLS changes regardless of apparent quality.

Other factors

  • No CODEOWNERS cover these paths.
  • The bug-hunting system found nothing this run; my two prior nits were test-precision only and have been addressed.
  • The PR description is thorough and the author already verified (and declined to link) the two auto-suggested issues, which is a good signal.
  • CI build #65367 was triggered for the latest commit; I did not verify its status.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Head is 1c65194, rebased onto current main. All seven review findings addressed and their threads resolved; both review bots report no remaining issues on this head.

Review-found additions since the rebase:

  • getEphemeralKeyInfo() on a resumed TLS 1.2 session now reports no ephemeral key, matching Node. 302b935.
  • rejectUnauthorized: null no longer bypasses certificate verification (pre-existing on tls.connect(), normalized to !== false at all five store sites). c3fcd1a.
  • setServername() called between new TLSSocket(socket, { isServer: false }) and _start() is now sent as the client SNI, matching Node. 7490447.
  • checkServerIdentity is no longer called on a resumed session, matching Node's onConnectSecure guard. 1c65194.

Ten new tests, all with a clear fail-before diff. The full test file is 25 pass / 0 fail under bun bd test.

Build 71057 finished 282 passed, 4 red. None of the four touch node:tls:

  • :windows: 11 aarch64 shard 1/8: test/js/sql/postgres-invalid-message-length.test.ts with ERR_POSTGRES_CONNECTION_REFUSED (zero TLS; same shard passed on 71051).
  • :darwin: 14 x64 shard 1/8: test/js/bun/http/proxy-stress-concurrent.test.ts (1 to 8 of 1200 https-proxy stress requests failed; uses fetch({tls:{...}}) which routes through src/runtime/webcore/fetch.rs and src/http/AsyncHTTP.rs, not node:tls) plus test/js/bun/terminal/terminal.test.ts (terminal API).
  • :darwin: 14 aarch64 shard 1/8: test/js/bun/webview/webview.test.ts (webview).
  • :alpine: 3.23 x64-baseline shard 16/N: test/js/bun/test/parallel/test-docker-build-debian.ts (timeout).

No failure in any js/node/tls/ or js/node/net/ file (one node/net RSS-threshold leak test missed its 8 MB bound at 10 MB on darwin x64 and then passed on retry). The runner tagged none of these as new to this PR. A job-level retry of each shard is what the build needs, but my Buildkite token is read-only.

robobun added 2 commits July 9, 2026 14:10
…TTLS, checkServerIdentity exceptions

getCipher().version always reported BoringSSL's hardcoded "TLSv1/SSLv3"
placeholder. Rebuild Node's strings from SSL_CIPHER_get_min_version plus
the key-exchange NID for the pre-1.2 suites.

getEphemeralKeyInfo() read the client's own private key (always empty on
a client without a certificate). Report the negotiated (EC)DHE group via
SSL_get_negotiated_group instead, matching Node for TLS <= 1.2 and its
{} on TLS 1.3 and static-RSA key exchanges.

An exception thrown from a user checkServerIdentity was caught by the
native handshake handler's error routing and emitted as a socket
'error'. Node does not guard the callback; defer the rethrow so it
escapes as an uncaught exception and 'secureConnect' does not fire.

new tls.TLSSocket(socket, { isServer: false }) + _start() threw
ERR_MISSING_ARGS because connect() received no port, path, or socket.
Hand the wrapped socket to connect(), and honor the constructor's own
rejectUnauthorized, which that path never routed through
Socket.prototype.connect.

Deleted the now-dead FFI: SSL_get_privatekey, EVP_PKEY_id,
EVP_PKEY_bits, EVP_PKEY_get1_EC_KEY (which deliberately leaked the
EC_KEY), EC_KEY_get0_group, EC_GROUP_get_curve_name,
SSL_CIPHER_get_version, the EVP_PKEY/EC_KEY/EC_GROUP opaque handles,
and the EVP_PKEY_DH/X25519/X448 constants.
…tderr in tests

Node's getEphemeralKeyInfo() always returns the same three own keys on a
client, with undefined values when no ephemeral key applies; toEqual
ignored undefined-valued keys and the comments wrongly described the
result as a bare {}. Use toStrictEqual against the exact shape, and
include the spawned fixture's stderr in the asserted object so an
unexpected crash trace shows up in the failure diff.
@robobun
robobun force-pushed the farm/cd758895/node-tls-client-compat branch from b5b3ab7 to 1faed51 Compare July 9, 2026 14:17
Comment thread src/runtime/socket/tls_socket_functions.rs Outdated
A resumed TLS 1.2 abbreviated handshake has no ServerKeyExchange, so
Node's SSL_get_peer_tmp_key is empty and getEphemeralKeyInfo returns the
three undefined keys. BoringSSL serializes the original handshake's
group_id into the session blob, so SSL_get_negotiated_group would
surface it on the resumed connection; gate it on SSL_session_reused.

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/tls.ts`:
- Around line 943-944: Normalize rejectUnauthorized to a real boolean in the TLS
option handling instead of storing the raw value: update the client-side
assignment in the relevant tls constructor/path so only an explicit false
disables verification, and mirror the same normalization in the server-side
constructor path as well. Use the existing rejectUnauthorized option handling in
the TLS initialization flow to locate both spots and ensure null, 0, and empty
string do not bypass certificate checks.

In `@test/js/node/tls/node-tls-connect.test.ts`:
- Around line 854-902: The `session` variable in `getEphemeralKeyInfo()` is
hoisted with `var` inside the `full` connection block, which makes the scope
unclear. Declare `session` with `let` before the `try` that uses `connect()`,
then assign it from `full.getSession()` so it remains available after
`full.destroy()` while making the test easier to read and maintain.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5226c5a3-e919-4cab-9a09-82c7ce41a750

📥 Commits

Reviewing files that changed from the base of the PR and between fc865b3 and 302b935.

📒 Files selected for processing (6)
  • src/js/node/net.ts
  • src/js/node/tls.ts
  • src/runtime/socket/tls_socket_functions.rs
  • test/js/node/test/common/boringssl.js
  • test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js
  • test/js/node/tls/node-tls-connect.test.ts

Comment thread src/js/node/tls.ts Outdated
Comment thread test/js/node/tls/node-tls-connect.test.ts
…fication

Node's CVE-2021-22939 fix checks `rejectUnauthorized !== false`: every
other value, including null, keeps certificate verification on. Bun's
client connect paths stored the raw option value and then checked it
for truthiness, so rejectUnauthorized: null silently bypassed
verification where Node rejects. Normalize to a real boolean at each
store site (the TLSSocket constructor, Server setSecureContext, and
the three Socket.prototype.connect TLS paths). The bindgen layer
already rejects 0/""/1 with ERR_INVALID_ARG_TYPE, so null was the
only non-boolean that reached these sites.

Also: declare `session` with let before the try in the resumption
test instead of hoisting with var.
Comment thread src/js/node/tls.ts
…t socket

Socket.prototype.connect unconditionally overwrites this.servername from
its options object, so a setServername() call between `new
TLSSocket(socket, { isServer: false })` and _start() was wiped and no
SNI was sent. Node preserves the value and sends it. Forward the
current servername in the connect() options so the handshake carries it.
Comment thread src/js/node/net.ts
Node's onConnectSecure guards the user callback with
`!this.isSessionReused()`: the identity was verified on the original
full handshake, so a resumed abbreviated handshake skips it. Bun called
it every time, so a side-effecting or throwing callback diverged (and
the new nextTick rethrow made a throw on the resumed call fatal). Add
the same guard to both handshake handlers.

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

All prior review feedback has been addressed and no new issues found, but this touches security-sensitive TLS paths (rejectUnauthorized normalization, checkServerIdentity exception routing, handshake FFI) so it's worth a human sign-off.

Extended reasoning...

Overview

This PR fixes six node:tls client divergences from Node.js across three source files and three test files (~490 lines net). The changes span:

  • src/runtime/socket/tls_socket_functions.rs: rewrites get_cipher version reporting and get_ephemeral_key_info to derive results from SSL_CIPHER_get_min_version/SSL_get_negotiated_group instead of the previous (broken/leaking) SSL_get_privatekey path; deletes seven now-dead FFI declarations.
  • src/js/node/net.ts: refactors both handshake handlers to route checkServerIdentity through a new runCheckServerIdentity helper that defers a rethrow via process.nextTick (so a user throw becomes an uncaught exception, matching Node), adds !self.isSessionReused() guards, and normalizes rejectUnauthorized to !== false at three connect sites.
  • src/js/node/tls.ts: makes _start() forward the wrapped socket + servername to connect() for the client STARTTLS pattern, honors rejectUnauthorized in the TLSSocket constructor, and normalizes it in server setSecureContext.
  • Ten new tests plus updates to two existing test helpers that encoded the old (wrong) behavior.

Security risks

This PR is squarely in security-sensitive territory:

  • rejectUnauthorized normalization across five sites directly controls whether TLS certificate verification runs. The change is a hardening (only explicit false disables verification, fixing a pre-existing null bypass matching CVE-2021-22939), but any error here could either bypass verification or break legitimate opt-outs.
  • checkServerIdentity rethrow changes error routing from a catchable socket 'error' to an uncaught exception. This matches Node, but widens blast radius for throwing callbacks — mitigated by the new isSessionReused() guard so it never fires on resumption.
  • New BoringSSL FFI declarations (SSL_version, SSL_get_negotiated_group, SSL_CIPHER_get_min_version, SSL_CIPHER_get_kx_nid) — all declared safe fn under the module's opaque-ZST convention; signatures look correct against BoringSSL headers.

I don't see a security regression — the rejectUnauthorized change is strictly a hardening, and the fail-closed direction is correct. But TLS verification logic is exactly where a subtle mistake has outsized consequences.

Level of scrutiny

High. This is production TLS handshake code. The individual changes are well-reasoned, empirically verified against Node v26.3.0, and thoroughly tested (each of the ten new tests has a documented fail-before/pass-after diff). But the combination of certificate-verification gating, exception-routing changes, and FFI surface changes in one PR warrants a maintainer's eyes rather than bot-only approval.

Other factors

  • All seven prior review findings (mine and CodeRabbit's) were addressed with follow-up commits and empirical re-verification against Node; every thread is resolved.
  • The bug-hunting system found no issues on the current head (1c65194).
  • Test coverage is strong: each fix has a dedicated test asserting the exact Node-matching behavior, and the PR description documents pass/fail on both ASAN debug and release builds.
  • The dead-code deletion (EVP_PKEY/EC_KEY FFI) is clean — grepped and confirmed no remaining callers.

Given the security-sensitive scope, deferring to a human reviewer per the approval guidelines.

@robobun

robobun commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up on the _start() part of this PR: #37664 takes a different route for the same scenario. The client branch of the TLSSocket constructor now runs the upgrade itself (the same one tls.connect({ socket }) uses), so both new TLSSocket(socket) + _start() and a plain write() without _start() work, and _start() becomes a no-op. The two _start() changes conflict textually in tls.ts; the other fixes here (getCipher().version, getEphemeralKeyInfo, checkServerIdentity exceptions) are untouched by #37664.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Ran into the _start() ERR_MISSING_ARGS part of this independently. The _start() change here is the same fix I arrived at (hand the parked _handle stream to connect(), forward this.servername), so I am not opening a separate PR for it. Two small things from testing that variant, in case they are worth folding in:

  1. The this.connect() fallthrough still throws for a TLSSocket that has nothing to start, e.g. new tls.TLSSocket(undefined, opts)._start() either before or after .connect(port). Node never throws from _start() (it parks the call on 'connect' or returns, wrap.js L1117-L1124), and a socket-less TLSSocket handshakes on its own once it connects here, so simply returning in that case is enough.
  2. A second _start() while the wrapped net.Socket is still connecting queues a second upgrade, and the second one fails later with Invalid socket from inside the once('connect') callback. this.connecting is set synchronously by the first connect(), so bailing out when it is set covers that window; once the socket is connected _handle is already the native handle, so the instanceof Duplex check covers the rest. Node aborts the process on a double start, so this is only robustness, not compat.

The variant with both guards plus ten tests for _start() (connected and connecting net.Socket, plain Duplex pair, setServername() before start, ssl.verifyError() with and without ca, socket-less before and after connect(), double start) is in 55beb44 (branch farm/44d78bc2/tls-start-wrapped-socket) if any of it is useful. All ten fail on main with ERR_MISSING_ARGS.

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.

1 participant