Repository navigation
Conversation
A TLS record that fails after the handshake (a bad MAC, a wrong version, a peer alert) made SSL_read fail with SSL_ERROR_SSL. uSockets dropped the OpenSSL reason and closed the socket with code 0, so node:tls emitted 'end' and 'close' with hadError false, and Bun.listen/connect saw a close with no error. Node reports the reason as the socket's ERR_SSL_<REASON> 'error' and then 'close' with hadError true. us_internal_ssl_on_data now closes with the reason string once the handshake has completed. NewSocket::on_close turns it into the same EPROTO SystemError the handshake failure path uses, and node:net decomposes it into library/function/reason and the ERR_SSL_* code. The root-cause picker prefers the oldest SSL-library entry in the error queue: BoringSSL queues Cipher functions:BAD_DECRYPT ahead of SSL routines:DECRYPTION_FAILED_OR_BAD_RECORD_MAC, where OpenSSL queues only the SSL entry.
|
Warning Review limit reached
On-demand reviews are free for the next 15 days. After that, they cost $0.25 per reviewed file. Or wait 44 seconds for your next included review. View limit detailsLimit details: You’ve used all 10 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (5)
Comment |
|
Status: reproduced with a TCP proxy that injects a bad application_data record after the server's handshake. node v26.3.0 reports ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC on the server and the alert on the client. bun reported a clean end on both. Closed in favour of #41272, which is the older fix for the same branch and matches Node's event order. The unique pieces of this branch (the bad-record proxy test, the SSL-library error picker on the handshake path) are noted there. |
|
Updated 11:50 PM PT - Sep 5th, 2026
❌ @robobun, your commit 1be57a8 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 41463That installs a local version of the PR into your bun-41463 --bun |
|
Applied the review suggestion: both tests now release the proxy and the server in a finally block (node-tls-server.test.ts) or through using (socket.test.ts) before the assertions run. The push follows with the next commit. |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked whether the new err.code === "EPROTO" gate in SocketHandlers2.close / SocketEmitEndNT could misfire on plain TCP sockets — the Rust side only builds the EPROTO SystemError when const SSL = true (tls_close_reason::<SSL> returns None otherwise), and tlsHandshakeError only decomposes into ERR_SSL_* when the message matches the OpenSSL format, so a stray non-TLS EPROTO would not get the wrong library/reason fields. The earlier note about test cleanup ordering has been addressed by the try/finally and using _proxy disposable in the follow-up commit.
Extended reasoning...
The confirmed inline finding covers the sibling SSLWrapper::handle_reading path in src/uws/lib.rs that this PR does not touch, so a human should look at scope. Separately, this run examined whether the JS-side EPROTO gate is over-broad (it is the default handler for plain TCP too) and ruled it out: the Rust tls_close_reason::<SSL> helper is const-gated on the SSL variant so a non-TLS close never carries the OpenSSL reason string, and tlsHandshakeError's regex only assigns ERR_SSL_* when the message is an OpenSSL error string. The stack-buffer reason[US_SSL_FATAL_ERROR_REASON_MAX] lifetime was also checked — ssl_close dispatches on_close synchronously within the same frame, so the pointer does not outlive its backing memory.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/uws/lib.rs— The fix only covers TLS backed by a realus_socket_t; the siblingSSLWrapper::handle_readingpath (used byupgradeDuplexToTLSand Windows named-pipe TLS) stillERR_clear_error()s at line 992 and callstrigger_close_callback()with no reason onSSL_ERROR_SSL/SYSCALL, sotls.connect({ socket: <Duplex or named pipe> })keeps emitting a cleanend/closeon a bad record instead ofERR_SSL_*(pre-existing on this path). Fix: capture the root-cause reason here the same way and thread it throughtrigger_close_callback→DuplexUpgradeContext::on_close(socket_body.rs:4408) /WindowsNamedPipeContext::on_closeintoTLSSocket::on_closeso both SSL_read implementations report the failure.Extended reasoning...
tls.connect({ socket })withsocketa generic Duplex, a Windows named-pipe net.Socket, or a net.Socket with unflushed writes (net.ts:1993, 2027, 2078) takesupgradeDuplexToTLS, which drives BoringSSL throughSSLWrapperin src/uws/lib.rs, not throughus_internal_ssl_on_data. When a post-handshake record fails to decrypt or a peer alert arrives,handle_readingat src/uws/lib.rs:989-1044 getsSSL_ERROR_SSL, immediately doesERR_clear_error()(line 992), setsfatal_error, and callstrigger_close_callback()(line 1063) with no reason payload. That reachesDuplexUpgradeContext::on_close(socket_body.rs:4395) which forwardsTLSSocket::on_close(p, socket, 0, None)— code 0, reason None — so the newtls_close_reason::<SSL>branch never fires and node:tls still emitsendthenclose(hadError=false). Node reportsERR_SSL_<REASON>regardless of transport. REVIEW.md requires same-class sibling sites ("fast/slow paths, POSIX/Windows branches") to be fixed together; the openssl.c change is one of two SSL_read drivers in the tree.Verification: pre-existing — The sibling path is real and unfixed.
src/uws/lib.rsis untouched by this diff (0 lines changed).SSLWrapper::handle_readingat lines 989-1063: onjust_read <= 0it callsSSL_get_errorthen immediatelyboring_sys::ERR_clear_error()at line 992 (discarding the OpenSSL reason queue), and forSSL_ERROR_SSL/SSL_ERROR_SYSCALLonly doesself.flags.set_fatal_error(true)…
|
On the SSLWrapper sibling (src/uws/lib.rs, the Duplex and named-pipe TLS path): it is a separate SSL_read driver with its own handler table, and #32929 (open) already adds the handshake-time reason there. The post-handshake counterpart for that path is tracked as a follow-up on top of #32929, so this PR stays on the us_socket_t path. Noted in the PR body. |
The EPROTO close branch returned before the pending-write settlement, so a write(buf, cb) still waiting on the native drain never got its callback. Fail it with the same ERR_SSL_* error first, like the native error dispatch does. The test now fails on the close when no error is emitted instead of waiting for the timeout.
|
The SSLWrapper counterpart (node:tls over a Duplex or a Windows named pipe) is #41507, stacked on this branch. It exports the error-queue picker added here as |
… a clean close (#41507) Stacked on #41463. Review the last commit only. ### Problem - `tls.connect({ socket: <Duplex> })` (and node:tls over a Windows named pipe) emits a clean `'end'` and `'close'` with `hadError` false when a TLS record fails after the handshake: a bad MAC, a wrong version, a peer's fatal alert. Node emits the socket's `ERR_SSL_<REASON>` `'error'` (for example `ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC`). - The cause is `SSLWrapper::handle_reading` (`src/uws/lib.rs:1030`). On `SSL_ERROR_SSL` it called `ERR_clear_error()` right after `SSL_get_error()` and closed through `Handlers::on_close`, which carried no reason. The alert BoringSSL sealed into the write BIO was dropped too. #41463 fixes the same bug for the uSockets engine. The SSLWrapper engine is a separate `SSL_read` driver, so that fix does not reach it. ### Fix - `handle_reading` takes the root-cause reason before the queue is cleared, through `us_ssl_take_fatal_reason`, the picker openssl.c uses for its own fatal close. It then flushes the sealed alert to the peer and closes with the reason. - `Handlers::on_close` carries `Option<&CStr>`. `UpgradedDuplex` and `WindowsNamedPipe` forward it into `TLSSocket::on_close` through the close reason slot that #41463 added. A named pipe close deferred by a busy writer keeps an owned copy. The fetch and WebSocket proxy tunnels ignore it. `write_data` no longer closes the wrapper while a fatal read is mid-dispatch, so a `'data'` listener that writes back cannot hide the reason. - Correct because the reason then reaches JS through the same `EPROTO` shape and the same `node:net` decomposition as the uSockets path. Both engines report one result for one failure. - Verified: `test/js/node/tls/node-tls-connect.test.ts` ("tls.connect over a Duplex reports a fatal post-handshake SSL error", three cases). All three fail on the #41463 base and pass with this branch. The same cases also pass on Node.js v26.3.0 (see the comment below). Also `test/js/node/tls/`, `test/js/bun/net/socket.test.ts`, and the fetch and WebSocket proxy suites. ### Background - `SSLWrapper` (`src/uws/lib.rs`) runs BoringSSL over memory BIOs for transports that are not a `us_socket_t`: a generic `Duplex`, a Windows named pipe, the fetch and WebSocket proxy tunnels. It calls its owner back through `Handlers` (`on_data`, `write`, `on_close`). - Error queue: OpenSSL keeps a per-thread error queue. A fatal `SSL_read` leaves the reason there. Node reports the oldest entry. BoringSSL can queue a cipher-layer entry first, so the picker takes the oldest SSL-library entry. - Fatal alert: on a failed read BoringSSL writes an alert record into the write BIO. Nothing pumps that BIO after a close, so `handle_reading` now flushes it before the close. The peer then gets its own `ERR_SSL_*ALERT_*` error. <details><summary>Notes</summary> - Repro: a TCP proxy between a `Duplex`-wrapped client and `tls.createServer`. After `secureConnect`, write an application_data record (`17 03 03 00 20` plus 32 bytes of `0x42`) toward the client. Node v26.3.0: `error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC`. Bun 1.4.3-canary: `end`, `close false`. With the fix: `error ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC`, `close true`. - Written toward the server instead, the server's `bad_record_mac` alert reaches the client as `ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC` (BoringSSL's name for the alert). - Node leaves the TLSSocket open after the `'error'`. Bun destroys it with the error, because the engine's only exit on a fatal read is a close. #41463 made the same choice for the uSockets path. - The alert flush was checked on the wire: the client sends a 19-byte application_data record (a TLS 1.3 encrypted alert) before the FIN. - The tests assert only what holds on both runtimes: the first event is `'error'` with the `ERR_SSL_*` code and `library: "SSL routines"`. The alert's code name comes from `process.features.openssl_is_boringssl` (BoringSSL: `SSLV3_ALERT_BAD_RECORD_MAC`, OpenSSL 3: `SSL/TLS_ALERT_BAD_RECORD_MAC`). - Self-reviewed: 9 concerns raised, 7 addressed (stacked on #41463 instead of main, the shared `net.ts` and `NewSocket::on_close` hunks dropped, the picker shared with openssl.c instead of a Rust copy, the node `'close'` wording fixed). Not addressed: the Http2SecureServer upgrade path (`_http2_upgrade.ts` `socketClose`) still drops the reason on this engine, and #32929 (the handshake-time reason on this engine) is not stacked under this PR. Both are separate changes. - #41272 is an alternative shape for the uSockets path (an `error` dispatch before the close). It states that it does not cover the SSLWrapper engine. If that shape lands instead of #41463, the reason capture and the alert flush here stay, and only the final dispatch in `DuplexUpgradeContext::on_close` changes. - Pre-existing failures in this container, not from this change: `SNICallback runs even when the requested servername matches the bind hostname` (localhost dual-stack) and `socket > should not call drain before handshake` (needs www.example.com). - Cross-checked with `cargo check -p bun_runtime --target x86_64-pc-windows-msvc`. </details> <!-- robobun:evidence:begin --> --- **[human-review]** gate passed · iteration 1 · 9 files touched <details><summary>fails on main (without fix)</summary> ```console ASAN without fix: 3 failed, 18 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-connect.test.ts bun test v1.4.3 (4ff9193) test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [2.59ms] (pass) should thow ECONNRESET if FIN is received before handshake [323.96ms] (pass) initializes authorizationError to null in the TLSSocket constructor [7.81ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [113.40ms] (pass) should be able to grab the JSStreamSocket constructor [13.14ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [80.19ms] (pass) tls.connect > should have peer certificate when using self asign certificate [83.18ms] (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 an ... (truncated) release without fix: 3 failed, 18 skipped bun test v1.4.3-canary.1 (4ff9193) test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [0.06ms] (pass) should thow ECONNRESET if FIN is received before handshake [6.51ms] (pass) initializes authorizationError to null in the TLSSocket constructor [0.16ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [3.49ms] (pass) should be able to grab the JSStreamSocket constructor [0.21ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [7.36ms] (pass) tls.connect > should have peer certificate when using self asign certificate [4.22ms] (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.c ... (truncated) ``` </details> <details><summary>passes on PR (with fix)</summary> ```console ASAN with fix: 18 skipped $ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/node/tls/node-tls-connect.test.ts bun test v1.4.3 (4ff9193) test/js/node/tls/node-tls-connect.test.ts: (pass) should have checkServerIdentity [2.58ms] (pass) should thow ECONNRESET if FIN is received before handshake [316.01ms] (pass) initializes authorizationError to null in the TLSSocket constructor [7.39ms] (pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [113.24ms] (pass) should be able to grab the JSStreamSocket constructor [12.50ms] (skip) tls.connect > should work with alpnProtocols (pass) tls.connect > Bun.serve() should work with tls and Bun.file() [75.11ms] (pass) tls.connect > should have peer certificate when using self asign certificate [80.40ms] (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 an ... (truncated) release with fix: 18 skipped $ bun scripts/build.ts --profile=release [configured] bun-profile → bun (stripped) in 602ms (unchanged) ninja: Entering directory `/workspace/bun/build/release' [1/7] cc obj/packages/bun-usockets/src/crypto/openssl.c.o [2/7] gen generated_host_exports.rs generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited [2/7] cargo bun_runtime → libbun_runtime.a �[1m�[92m Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core) �[1m�[92m Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno) �[1m�[92m Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr) �[1m�[92m Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys) �[1m�[92m Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety) �[1m�[92m Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64) �[1m�[92m Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys) �[1m�[92m Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys) �[1m�[92m Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd) �[1m�[92m Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp) �[1m�[92m Compiling�[0m bun_brotli v0 ... (truncated) ``` </details> <details><summary>diff hotspot</summary> ``` packages/bun-usockets/src/crypto/openssl.c | 20 +++- src/http/ProxyTunnel.rs | 2 +- .../websocket_client/WebSocketProxyTunnel.rs | 2 +- src/runtime/socket/UpgradedDuplex.rs | 7 +- src/runtime/socket/WindowsNamedPipe.rs | 22 +++- src/runtime/socket/WindowsNamedPipeContext.rs | 8 +- src/runtime/socket/socket_body.rs | 18 +-- src/uws/lib.rs | 73 +++++++++--- test/js/node/tls/node-tls-connect.test.ts | 124 +++++++++++++++++++++ 9 files changed, 234 insertions(+), 42 deletions(-) ``` </details> **gate history** · 3 passed · 0 rejected · iteration 1 <details><summary>evidence per changed file</summary> ``` file reads edits tests packages/bun-usockets/src/crypto/openssl.c 0 0 25 src/http/ProxyTunnel.rs 0 0 25 src/http_jsc/websocket_client/WebSocketProxyTunnel.rs 0 0 25 src/runtime/socket/UpgradedDuplex.rs 1 2 25 src/runtime/socket/WindowsNamedPipe.rs 1 0 25 src/runtime/socket/WindowsNamedPipeContext.rs 1 0 25 src/runtime/socket/socket_body.rs 3 5 25 src/uws/lib.rs 6 7 25 test/js/node/tls/node-tls-connect.test.ts 8 4 25 ``` </details> <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Problem
SSL_readfail withSSL_ERROR_SSL. uSockets dropped the OpenSSL reason and closed the socket with code 0. node:tls emitted'end'then'close'withhadErrorfalse.Bun.listen/Bun.connectgot aclosewith no error. Node reports the socket'sERR_SSL_<REASON>'error'(for exampleERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC), then'close'withhadErrortrue.us_internal_ssl_on_data(packages/bun-usockets/src/crypto/openssl.c:2656).ssl_park_fatal_reasononly parks a reason while the handshake is unfinished, because the handshake-failure dispatch is its only consumer. After the handshake the reason went nowhere and the close looked clean.Fix
us_internal_ssl_on_datacloses with the reason string (ssl_close(s, 0, reason)) once the handshake has completed. The alert BoringSSL sealed on the failure still goes out first, so the peer gets its ownERR_SSL_*ALERT_*error.NewSocket::on_closeturns a non-null close reason into the sameEPROTOSystemErrorthe handshake-failure path already uses (code: "EPROTO",syscall: "read", message is theerror:...:SSL routines:...string). The raw half of anupgradeTLSpair keeps its plain close.tlsHandshakeErrorintolibrary/function/reasonandERR_SSL_<REASON>, and destroys the socket with it, listener or not, like node'sTLSWraponerror.Cipher functions:BAD_DECRYPTahead ofSSL routines:DECRYPTION_FAILED_OR_BAD_RECORD_MAC, where OpenSSL queues only the SSL entry. This applies to the handshake path too.test/js/node/tls/node-tls-server.test.ts("a bad record after the handshake surfaces as ERR_SSL_* on both peers") andtest/js/bun/net/socket.test.ts("a TLS record that fails to decrypt after the handshake closes both peers with an EPROTO error"). Both fail on 1.4.3-canary (the server sees a cleanend). Alsotest/js/node/tls/,test/js/node/http2/,test/js/node/https/,test/js/bun/net/, and the nodetest-tls-*/test-https-*parallel files.Background
loop.croutes readable events for a socket withs->ssltous_internal_ssl_on_data, which runsSSL_readand dispatches plaintext. A fatalSSL_readsends the alert through the write BIO and the function closes the socket.us_socket_close(s, code, reason)carries an opaquereasonpointer to the group'son_close. Nothing in bun passed a non-null one before. It is now the OpenSSL reason string (a stack buffer that lives for the close dispatch).ssl_park_fatal_reasonstores the OpenSSL error string per loop for a failure before the handshake is reported.ssl_dispatch_parked_reasonturns it into theEPROTOhandshake verdict that node:tls already maps toERR_SSL_*.Notes
tls.connectclient and atls.createServerserver. Once the server emitssecureConnection, the proxy writes an application_data record (17 03 03 00 20plus 32 bytes of0x42) toward the server. node v26.3.0 server:ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC, client:ERR_SSL_SSL/TLS_ALERT_BAD_RECORD_MAC. bun 1.4.2 and canary:end,close falseon both sides.ERR_SSL_SSLV3_ALERT_BAD_RECORD_MAC. The handshake path already has the same naming difference.BAD_PACKET_LENGTHunder BoringSSL (too short for an AEAD tag), so the test uses a 32-byte body to exercise the AEAD open.tls.Serversockets keep an internal'error'listener (onSocketTLSError, added in node:tls,node:net: follow-ups from the v26.3.0 review (error routing, manualStart reads, handshake timeout, setSecureContext) #35006) after control is released, so in bun such an error is not uncaught when the user attached no listener. Node throws there. That is pre-existing and not changed here.SSL_write). This PR covers the read side.src/uws/lib.rs(node:tls over a generic Duplex, Windows named pipes) is a separateSSL_readdriver. node:tls: report the fatal TLS alert when a handshake over a Duplex fails #32929 adds its handshake-time reason. Its post-handshake counterpart is tracked as a follow-up on top of node:tls: report the fatal TLS alert when a handshake over a Duplex fails #32929.SNICallback runs even when the requested servername matches the bind hostname(localhost dual-stack, fails without the change),should not call drain before handshake(needs www.example.com).