node:tls: make client-side new TLSSocket(socket) upgrade the socket it wraps - #37664
node:tls: make client-side new TLSSocket(socket) upgrade the socket it wraps#37664robobun wants to merge 3 commits into
Conversation
…t wraps The constructor stored the wrapped net.Socket itself as _handle and nothing ever upgraded it: the first write threw "socket.@Write is not a function", later writes were reported written and dropped, no handshake happened, _start() threw ERR_MISSING_ARGS and destroy() threw "handle.close is not a function". The client branch now goes through the same upgrade as tls.connect({ socket }) (a new net.Socket.prototype[kUpgradeClientTLS], the counterpart of the server-side wrap), so the handshake starts in the constructor and _handle is a TLS handle. Like Node's constructor wrap it gets _finishInit only: 'secure', the chain verdict in authorized / ssl.verifyError(), no hostname check against a connect() host it does not have and no 'secureConnect'. _start() becomes a no-op, and the [buntls] socket field plus the dead tls.socket fallbacks that existed for it are removed. Wrapping a generic stream exposed two more gaps on the shared duplex path: the stream's own 'error' was unhandled (so it surfaced as an uncaught exception and the stream's 'close' was never delivered), and a throwing write() came back out of UpgradedDuplex as the JSC::Exception cell instead of the thrown value, which JS then tried to use as an error (an isSymbol() assertion in debug builds). Both upgrade paths now forward the stream's error into the TLS socket, and UpgradedDuplex hands on the thrown value. Destroying a wrap whose socket has not connected yet also destroys that socket, as closing Node's wrap does.
|
Warning Review limit reached
Next review available in: 15 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
Comment |
|
Updated 4:50 PM PT - Aug 11th, 2026
✅ @robobun, your commit 0b3d4b900139ca4da05e9dc761979cda34941f05 passed in 🧪 To try this PR locally: bunx bun-pr 37664That installs a local version of the PR into your bun-37664 --bun |
There was a problem hiding this comment.
I reviewed this PR and the bug hunting system found no issues. Because it reworks a security-sensitive TLS path (client-side new TLSSocket(socket) upgrade, handshake-complete verification semantics), explicitly flags deliberate Node divergences for review, and overlaps with #32824 / #35842, a human should sign off on the design choices.
What was reviewed:
- The new
kUpgradeClientTLSpath reuses the existingconnect({ socket })upgrade;kStandaloneWrapcorrectly gatescheckServerIdentity/'secureConnect'while keeping therejectUnauthorizeddestroy. - Removed
tls.socket/connectionfallbacks ininternalConnect/internalConnectMultiplewere dead once_start()became a no-op — no other reader. take_exception→take_errorinUpgradedDuplex.rsmatches the otheron_errorsites in the file.listenToUpgradedDuplexreplaces four identical listener blocks; the added'error'forwarder and pre-connectdestroyConnectionhook are removed/superseded on the paths that take ownership.
Extended reasoning...
Overview
This PR makes client-side new tls.TLSSocket(socket) actually upgrade the wrapped socket (previously it stored the raw net.Socket as _handle and never handshaked). It touches src/js/node/tls.ts (constructor branch, _start(), [buntls]), src/js/node/net.ts (new kUpgradeClientTLS prototype method, kStandaloneWrap flag in onClientHandshakeComplete, listenToUpgradedDuplex helper, pre-connect destroy hook, dead-code removal in three connect paths), src/runtime/socket/UpgradedDuplex.rs (three take_exception → take_error), a new symbol, and 13 new tests.
Security risks
This is squarely in the TLS verification path. The kStandaloneWrap flag suppresses checkServerIdentity and 'secureConnect' for constructor wraps — the PR argues this matches Node's _finishInit-only semantics (no onConnectSecure) and cites the source lines, and it still enforces rejectUnauthorized on the chain verdict. The author also notes Bun is stricter than Node here (Node verifies nothing on a constructor wrap). That reasoning looks sound and is well-tested, but changing which verification steps run for a TLS client API is exactly the kind of decision a maintainer should confirm.
Level of scrutiny
High. TLS handshake/verification semantics, a Node-compat API used by real drivers (mysql, http2-wrapper/got), explicit "deliberate differences from Node, for review" in the description, and overlap with two other open PRs that a maintainer needs to reconcile.
Other factors
The change is thoroughly tested (13 new cases covering the fd-adopting wrap, duplex wrap, _start(), rejectUnauthorized, chain-vs-hostname, http2-wrapper probe, destroy-before-connect, and stream-error/throwing-write for all three wrap flavours), the dead-code removals are justified in the description, and the Rust change is a straightforward correctness fix matching sibling call sites. Nothing blocks on correctness from what I can see; the deferral is for the design/compat trade-offs the author explicitly surfaced.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
On the three PRs the duplicate check lists:
The comment-length review threads are addressed in 0b3d4b9 (each new comment is now a single line, plus a Node source link where the neighbouring code does the same). |
There was a problem hiding this comment.
I reviewed this PR and the automated bug-hunt found no issues. This still needs a human look: it reworks the client-side TLS upgrade path and handshake-complete semantics (skipping hostname verification for constructor wraps), and the description explicitly flags deliberate Node divergences and three overlapping PRs (#32824, #36909, #35842) for a maintainer to reconcile.
What was reviewed:
kStandaloneWrapgating inonClientHandshakeComplete— chain verification andrejectUnauthorizedstill run; onlycheckServerIdentityand'secureConnect'are skipped, matching Node's_finishInit-without-onConnectSecure.- The removed
tls.socket/connectionfallbacks ininternalConnect/internalConnectMultiple— the locals were write-only after the[buntls]change, so removal is dead-code cleanup. listenToUpgradedDuplexapplied to all four duplex-attach sites; the new error forwarder guards on!this.destroyed.
Extended reasoning...
Overview
This PR makes client-side new tls.TLSSocket(socket) actually upgrade the wrapped socket by routing it through the same connect({ socket }) path tls.connect uses, via a new Socket.prototype[kUpgradeClientTLS]. It touches src/js/node/tls.ts (constructor branch, _start() → no-op, [buntls] no longer returns socket), src/js/node/net.ts (onClientHandshakeComplete gains a kStandaloneWrap branch, four duplex-listener attach sites factored into listenToUpgradedDuplex with an added 'error' forwarder, a destroy-before-connect hook, and dead connection/tls.socket locals removed from internalConnect/internalConnectMultiple), a three-line take_exception → take_error fix in UpgradedDuplex.rs, and 13 new tests.
Security risks
The change is squarely in TLS verification territory. When kStandaloneWrap is set, onClientHandshakeComplete skips checkServerIdentity and the 'secureConnect' emit. The PR argues (with Node source citations) that this matches Node's behaviour for a constructor wrap, which never installs onConnectSecure, and it keeps rejectUnauthorized enforcement (stricter than Node). The chain verdict still lands in authorized/authorizationError/ssl.verifyError(). That reasoning looks correct against the cited Node source, but skipping hostname verification on a client TLS path is exactly the kind of decision REVIEW.md says a maintainer should sign off on.
Level of scrutiny
High. This is production TLS handshake/verification logic with an intentional Node divergence (ClientHello at construction rather than _start(), so setServername()/setSession() between construction and _start() are too late) that the author explicitly flags "for review". There are also three competing/overlapping open PRs whose reconciliation is a maintainer call.
Other factors
The bug-hunting system raised and ruled out one candidate (whether kStandaloneWrap needs a constructor-time default — it does not, the symbol read is undefined-safe). Test coverage is thorough (13 new cases including the negative contract for 'secureConnect', the duplex error/throw paths, and the http2-wrapper probe). All comment-cop threads are resolved. Given the security surface and the design questions the author raises for review, deferring to a human is the right call.
|
Cross-reference: the fifth Two more variants of the stream-error case that might be worth covering here, checked against node v26.3.0 with |
Problem
new tls.TLSSocket(raw), is inert on Bun 1.4.0 and main: the first write throwsTypeError: socket.@write is not a function, later writes returntrueand are dropped, and no handshake starts. Node handshakes and passes data._start()(what themysqldriver calls) throwsERR_MISSING_ARGSanddestroy()fails withhandle.close is not a function.tls.connect({ socket })had an upgrade path.tls.connect({ socket: duplex })): its'error'had no listener, so it became an uncaught exception and'close'never fired; and a throwingwrite()reached the error handler as an exception cell, not the thrown value (ASSERTION FAILED: isSymbol()on debug,TypeError: Cannot convert a symbol to a stringon release).Fix
tls.connect({ socket }), so the handshake starts in the constructor and_start()is a no-op. Check: after construction_handleis never the stream passed in, and_handle._parentWrap(read by http2-wrapper, so bygot, at import) is still set._finishInithalf of completion:'secure'fires and the verdict lands inauthorized/ssl.verifyError(); no hostname check, no'secureConnect'. Deliberate differences from Node:rejectUnauthorized: trueis still enforced, and the ClientHello goes out at construction, soservername/sessionmust be constructor options.'error'into a destroy of the TLS socket, the native engine passes the thrown value (same hunk as node:tls: pass the thrown Error, not the internal exception wrapper, to 'error' over a Duplex transport #36909, byte-identical), and destroying a wrap before its socket connects destroys that socket too. Pre-handshake writes over a generic Duplex are still lost; pre-existing, tracked separately.write()case aborts a debug build); all pass with it. Vendored Node tls / https / net / http-upgrade tests pass; twotest/js/node/tls/tests fail identically on main in this environment.Background
new tls.TLSSocket(existingSocket), optionally followed by_start();tls.connect()is the other entry point and adds client checks on top.node:neta socket's_handlemust be a native handle, since_writeand_destroycall into it. Making an existing socket speak TLS means the native side takes the connection over (adopting the fd, or driving a generic Duplex throughUpgradedDuplex) and hands back a TLS handle to install as_handle._finishInit(emit'secure', record the verdict), which every TLSSocket gets, andonConnectSecure(hostname check,rejectUnauthorized,'secureConnect'), which onlytls.connect()installs.take_exceptionvstake_errorin the JSC bindings: the first yields the engine's exception wrapper cell, the second the value JS threw. The JS error handlers expect the latter.new TLSSocket(new PassThrough())._handle._parentWrap.constructorat import time, so a client wrap must expose_handle._parentWrapsynchronously and then fail quietly.Original description
Reproduction
The STARTTLS client shape: wrap a connected socket yourself instead of calling
tls.connect({ socket }).Node v26.3.0:
write -> true,secure TLSv1.3,data echo:tickper tick.Bun 1.4.0 (and main): the first write throws
every later write returns
trueand is dropped, no handshake ever starts (raw.bytesWrittenstays 0, nosecure/error/close),t._start()(what themysqldriver calls after constructing the wrap) throwsERR_MISSING_ARGS, andt.destroy()fails withhandle.close is not a function.Cause
The client branch of the
TLSSocketconstructor stored the wrappednet.Socketitself asthis._handleand nothing ever upgraded it, sonet.Socket's_write/_destroycalled native handle methods on a JS socket. Only the server wrap (isServer: true) andtls.connect({ socket })have upgrade paths._start()calledconnect()with no arguments, which throws.Fix
tls.ts: the client branch now drives the same upgradetls.connect({ socket })uses, through a newnet.Socket.prototype[kUpgradeClientTLS](the counterpart of the existing server-wrap method), so the handshake starts in the constructor and_handleis a TLS handle. The_handle._parentWrapthat http2-wrapper (and thereforegot) reads at import time is kept._start()is a no-op: the handshake is already running. Thesocket: this._handlefield of[buntls]and thetls.socketfallbacks in the three connect paths existed only for the old_start()and are removed.net.tsonClientHandshakeComplete: a constructor wrap gets Node's_finishInitsemantics, since Node installsonConnectSecurefromtls.connect()only:'secure'is emitted, the chain verdict lands inauthorized/authorizationError/ssl.verifyError(), there is no hostname check against aconnect()host the wrap does not have, and no'secureConnect'.rejectUnauthorized: truestill refuses an unverifiable peer, reported once through the destroy (the standalone server wrap already behaves this way). Node itself verifies nothing on a constructor wrap; themysqldriver's STARTTLS, the main user of this API, readsssl.verifyError()from its'secure'listener and works with either.tls.connect({ socket: duplex })before this change):'error'had no listener, so it surfaced as an uncaught exception and the stream's'close'was never emitted. Both upgrade paths now attach the four engine listeners plus an error forwarder (listenToUpgradedDuplex), destroying the TLS socket with the stream's error, which is where Node routes it too.UpgradedDuplex::call_write_or_endpassedtake_exception(), theJSC::Exceptioncell, to the error handler; JS then read properties off it (ASSERTION FAILED: isSymbol()inJSValue::synthesizePrototypein debug builds,TypeError: Cannot convert a symbol to a stringin release). It now passes the thrown value withtake_error(), like the other handler sites. This is the same three-line hunk as node:tls: pass the thrown Error, not the internal exception wrapper, to 'error' over a Duplex transport #36909 (kept byte-identical so either lands first without conflict); it is carried here because the client wrap makes the vendoredtest-tls-socket-allow-half-open-optionreach it. node:tls: pass the thrown Error, not the internal exception wrapper, to 'error' over a Duplex transport #36909 additionally fixes theon_dataconversion site and has its own test.JSStreamSocketdoes; the hook is removed once the upgrade takes over.Deliberate differences from Node, for review: the ClientHello is sent at construction rather than on
_start()/first write, sosetServername()/setSession()between construction and_start()are too late (passservername/sessionin the constructor options instead); andrejectUnauthorized: trueis enforced on the wrap. Writes before the handshake are delivered on the normal (fd adopting) path; over a generic Duplex they are lost, which is pre-existing behaviour oftls.connect({ socket: duplex })and tracked separately, so the duplex test here writes after'secure'.Overlaps: #32824 makes
_start()perform the upgrade lazily (this change starts it in the constructor instead, so_start()and a write without_start()both work); #36909 carries the identicalUpgradedDuplexhunk (above); #35842 adds acloseSocketHandlefallback for the same root cause (with this change_handleis never a plain stream, and its two scenarios are covered by the destroy tests here).Verification
13 new tests in
test/js/node/tls/node-tls-connect.test.ts(write after wrap over TCP,_start(),rejectUnauthorizedrejection, chain-but-not-hostname versustls.connect({ socket }), wrap over an in-memory Duplex pair, the http2-wrapper probe exiting quietly, destroy before connect fornet.SocketandDuplex, and stream errors / throwingwrite()for the client wrap, the server wrap andtls.connect({ socket })). Without the change every one of them except the probe test (which guards existing behaviour) fails, and the throwingwrite()case aborts a debug build on the assertion above; all pass with the change.test/js/node/tls/passes apart from two tests that fail identically on main in this environment (SNICallback ... bind hostname, which needslocalhostto resolve consistently, and the root-certificate Worker race hitting its 5s timeout on a debug build). The vendoredtest-tls-*,test-https-*,test-net-*andtest-http-upgrade*parallel tests pass, includingtest-tls-socket-allow-half-open-option(which wraps a write-less Duplex on the client side and previously only passed because the wrap was inert),test-http-upgrade-reconsume-streamandtest-tls-transport-destroy-after-own-gc.