tls: reject a bad server chain before the client certificate goes out in fetch, SQL, Redis and WebSocket clients
Fixes #43684
Problem
fetch,Bun.SQL(postgres and mysql,verify-caorverify-full),Bun.RedisClientandWebSocketsend the mTLS client certificate to a server whose chain fails verification. The client then rejects the server (UNABLE_TO_VERIFY_LEAF_SIGNATURE), but the server already has the certificate.- These clients enforce
rejectUnauthorizedinon_handshake, after the handshake completes. The verify callback fromus_internal_ssl_attach(packages/bun-usockets/src/crypto/openssl.c:1796) always returns 1, so the client's Certificate flight goes out before the verdict. node:tlsandBun.connectinstallus_internal_ssl_set_inline_reject(src/runtime/socket/socket_body.rs:1515). Nothing else called it.
Fix
- Add
us_socket_set_inline_reject(us_socket_t*), a socket-level form ofus_internal_ssl_set_inline_reject, exposed throughbun_uws_sys. It is a no-op on a server socket or after the handshake. - Each client calls it before its handshake starts, under the condition its
on_handshakeuses to reject:HTTPClient::on_open,WebSocketUpgradeClient::handle_open, the valkeySocketHandler::on_open, and the postgres and mysql TLS upgrades. - The existing handshake drive then keeps the client's final flight off the wire and fails the handshake with the X509 verdict. The socket is marked fatal first, so the close that follows is a bare FIN (no close_notify the peer cannot read, no wait for a reply). Each client's failure branch maps that verdict to the error it produced before. A client that accepts a bad chain is untouched.
- Verified:
test/js/bun/net/tls-reject-before-client-cert.test.ts(21 rows over TLS 1.3 and 1.2, 12 fail on stock bun). The issue's repro reports no leak. The fetch, WebSocket, valkey, node:tls and mariadb mock TLS suites pass. Self-reviewed: 3 concerns raised, 2 addressed, 1 deferred (see Notes).
Background
- A TLS 1.3 client receives the server's Certificate and Finished before it sends its own Certificate. A client that aborts on a bad chain during the handshake never reveals its identity to a server it refuses.
- usockets drives these handshakes in
openssl.c. The inline-reject verify callback records a failed chain in SSL ex_data. The BIO write hook andssl_trigger_handshakethen drop the post-verify output and dispatchon_handshakewith success 0. - The policy is per request or per connection and the
SSL_CTXcan be shared, so the hook runs per socket, not inus_internal_ssl_attach.
Notes
- Not covered, deferred: TLS that runs in
SSLWrapper(src/uws/lib.rs) instead of a usockets socket. That is the fetch HTTPS proxy tunnel (src/http/ProxyTunnel.rs), the WebSocket proxy tunnel (WebSocketProxyTunnel.rs),tls.connect({ socket })over a duplex (UpgradedDuplex.rs) and Windows named pipes. The recording half of the mechanism applies there (socket_body.rs already installs it on a duplex, where it is inert), butSSLWrapper::update_handshake_statehas no tripped check. The port is: export an SSL-levelus_internal_ssl_inline_reject_tripped(SSL*), and in the wrapper afterSSL_do_handshake, on tripped, reset the write BIO, mark the handshake failed withget_verify_error(), and close. Plus call sites inProxyTunnel::on_openandWebSocketProxyTunnel::start. It touches a wrapper shared with server-side injected sockets, so it belongs in its own PR with its own proxy tests. HTTP/3 is not covered either. - Also not covered: a server whose chain verifies but whose name does not match. Every client checks the hostname after the handshake, so that server still receives the client certificate before the client rejects it with
ERR_TLS_CERT_ALTNAME_INVALID. Moving that check into the handshake is follow-up work. CppWebSocket::reject_unauthorizedno longer enters and exits the event loop around the C++ field read. The exit ran a microtask checkpoint from the socket open callback.Bun.RedisClient.connect()settles from the close event withERR_REDIS_CONNECTION_CLOSED, before and after this change. The verify error goes to the pending commands. The test asserts the code connect() produces.- The MySQL client has the same shape as the postgres one (TLS upgrade after
SSLRequest,do_handshakeenforcesverify-caandverify-full), so it gets the same call. The issue did not list it. - The review asked for negative controls (a client that accepts a bad chain still completes the handshake, including SQL
verify-fullwithrejectUnauthorized: false), a row on the default trust store with noca, and TLS 1.2 rows (the client's Certificate is written before the server's Finished there, and the parked-write retry is what catches the rejection). All are in the test file. - The
reject_unauthorizedargument ofus_socket_adopt_tlsis only read for server sockets. The SQL callers keep passingfalsethere and install the client policy withset_inline_rejectinstead, becausesocket_body.rspasses the raw config value toadopt_tlswhile its reject policy also depends on the VM default. - The mysql test pauses and unshifts the raw socket before the server-side
tls.TLSSocketwrap (the pattern fromtest/js/sql/sql-mariadb-json.test.ts). When that TLS socket closes after a failed handshake, the raw socket stays open, so the test destroys it in theclosehandler. That is a separate node:tls server-side quirk. test/js/node/tls/node-tls-server.test.tshas one failing test, "SNICallback runs even when the requested servername matches the bind hostname". It fails on main without this diff as well.- Suites run with the fixed debug build:
test/js/web/fetch/fetch.tls.test.ts,fetch.tls.wildcard.test.ts,fetch.tls.ipv6.test.ts,fetch-tls-abortsignal-timeout.test.ts,test/js/web/websocket/websocket.tls.ipv6.test.ts,websocket.test.ts,test/js/valkey/valkey-tls-verify.test.ts,test/js/node/tls/fetch-tls-cert.test.ts,node-tls-connect.test.ts,node-tls-server.test.ts,test/js/bun/http/bun-serve-ssl.test.ts,tls-keepalive.test.ts,test/js/sql/sql-mariadb-json.test.ts, andtest/js/node/test/parallel/test-tls-close-error.js,test-tls-client-verify.js,test-tls-connect-simple.js,test-https-client-reject.js,test-tls-client-reject.js.
[human-review] gate passed · iteration 4 · 13 files touched
fails on main (without fix)
ASAN without fix: 12 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/net/tls-reject-before-client-cert.test.ts
bun test v1.4.3 (367d939d9)
test/js/bun/net/tls-reject-before-client-cert.test.ts:
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > control: with the right CA the server does see the client certificate [1009.49ms]
201 | test("fetch", async () => {
202 | await using srv = await mtlsServer({ onSecure: httpOk, maxVersion });
203 | const err = await settle(fetch(`https://localhost:${srv.port}/`, { tls: mtls }));
204 | expect(err?.code).toBe("DEPTH_ZERO_SELF_SIGNED_CERT");
205 | await srv.seen.closed;
206 | expect(srv.seen.peerCN).toBeNull();
^
error: expect(received).toBeNull()
Received: "agent3"
at <anonymous> (/workspace/bun/test/js/bun/net/tls-reject-before-client-cert.test.ts:206:31)
(fail) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > fetch [161.56ms]
210 | test("fetch with the default trust store (no ca)", async (
... (truncated)
release without fix: all passed
bun test v1.4.3-canary.1 (fa30b2bbd)
test/js/bun/net/tls-reject-before-client-cert.test.ts:
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > control: with the right CA the server does see the client certificate [17.78ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > fetch [4.57ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > fetch with the default trust store (no ca) [4.48ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > WebSocket [3.51ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > Bun.RedisClient [3.20ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > Bun.SQL postgres sslmode=verify-full [6.41ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > Bun.SQL mysql sslmode=verify-full [4.01ms]
(pass) TLSv1.2: a rejecting client sends no client certificate to a server whose chain fails > control: with the right CA the server does see the
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/net/tls-reject-before-client-cert.test.ts
bun test v1.4.3 (367d939d9)
test/js/bun/net/tls-reject-before-client-cert.test.ts:
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > control: with the right CA the server does see the client certificate [646.94ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > fetch [103.13ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > fetch with the default trust store (no ca) [73.14ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > WebSocket [74.63ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > Bun.RedisClient [62.71ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > Bun.SQL postgres sslmode=verify-full [285.20ms]
(pass) TLSv1.3: a rejecting client sends no client certificate to a server whose chain fails > Bun.
... (truncated)
release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 792ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/125] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[2/125] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-3.cpp.o
[3/125] cxx obj/unified/UnifiedSource-src_jsc_bindings-0.cpp.o
[4/125] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[5/125] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[6/125] cxx obj/unified/UnifiedSource-src_jsc_bindings_webcore-2.cpp.o
[7/125] cxx obj/src/jsc/bindings/BunProcess.cpp.o
[8/125] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[9/125] cxx obj/unified/UnifiedSource-src_jsc_bindings-2.cpp.o
[10/125] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[11/125] cxx obj/unified/UnifiedSource-src_runtime_webview-0.cpp.o
[12/125] cxx obj/src/jsc/bindings/ZigGlobalObject.cpp.o
[13/125] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[14/125] cxx obj/codegen/ZigGeneratedClasses.cpp.o
[15/125] cc obj/packages/bun-usockets/src/bsd.c.o
[16/125] cc obj/packages/bun-usockets/src/context.c.o
[17/125] cc obj/packages/bun-usockets/src/soc
... (truncated)
diff hotspot
packages/bun-usockets/src/crypto/openssl.c | 14 +
packages/bun-usockets/src/libusockets.h | 5 +
src/http/lib.rs | 4 +
src/http_jsc/websocket_client/CppWebSocket.rs | 9 +-
.../websocket_client/WebSocketUpgradeClient.rs | 6 +
src/install/dependency.rs | 2 +-
src/paths/string_paths.rs | 5 -
src/runtime/valkey_jsc/js_valkey.rs | 6 +
src/sql_jsc/mysql/MySQLConnection.rs | 7 +-
src/sql_jsc/postgres/PostgresSQLConnection.rs | 7 +-
src/uws_sys/socket.rs | 8 +
src/uws_sys/us_socket_t.rs | 7 +
.../bun/net/tls-reject-before-client-cert.test.ts | 331 +++++++++++++++++++++
13 files changed, 396 insertions(+), 15 deletions(-)
gate history · 4 passed · 0 rejected · iteration 4
evidence per changed file
file reads edits tests
packages/bun-usockets/src/crypto/openssl.c 4 4 28
packages/bun-usockets/src/libusockets.h 1 2 28
src/http/lib.rs 1 2 28
src/http_jsc/websocket_client/CppWebSocket.rs 1 1 28
src/http_jsc/websocket_client/WebSocketUpgradeClient.rs 1 1 28
src/install/dependency.rs 0 0 28
src/paths/string_paths.rs 0 0 28
src/runtime/valkey_jsc/js_valkey.rs 1 1 28
src/sql_jsc/mysql/MySQLConnection.rs 1 1 28
src/sql_jsc/postgres/PostgresSQLConnection.rs 1 2 28
src/uws_sys/socket.rs 1 1 28
src/uws_sys/us_socket_t.rs 1 2 28
test/js/bun/net/tls-reject-before-client-cert.test.ts 5 8 28
root cause · written by the author bot
Bun's usockets TLS layer verified the server certificate chain only after the handshake completed, so in TLS 1.3 the client had already sent its Certificate message before the chain failure was observed, leaking the mTLS client certificate to servers that fail verification in fetch, Bun.SQL, Bun.RedisClient and WebSocket. The fix adds inline certificate rejection in the OpenSSL verify callback for eligible client sockets, marking a failed chain as a fatal handshake error so the connection aborts before the client's Certificate message goes out. HTTP, WebSocket, Valkey, MySQL and PostgreSQL …