tls: reject a bad server chain before the client certificate goes out in fetch, SQL, Redis and WebSocket clients

Merged
RO
2mo ago
+396-15

Fixes #43684

Problem

  • fetch, Bun.SQL (postgres and mysql, verify-ca or verify-full), Bun.RedisClient and WebSocket send 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 rejectUnauthorized in on_handshake, after the handshake completes. The verify callback from us_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:tls and Bun.connect install us_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 of us_internal_ssl_set_inline_reject, exposed through bun_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_handshake uses to reject: HTTPClient::on_open, WebSocketUpgradeClient::handle_open, the valkey SocketHandler::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 and ssl_trigger_handshake then drop the post-verify output and dispatch on_handshake with success 0.
  • The policy is per request or per connection and the SSL_CTX can be shared, so the hook runs per socket, not in us_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), but SSLWrapper::update_handshake_state has no tripped check. The port is: export an SSL-level us_internal_ssl_inline_reject_tripped(SSL*), and in the wrapper after SSL_do_handshake, on tripped, reset the write BIO, mark the handshake failed with get_verify_error(), and close. Plus call sites in ProxyTunnel::on_open and WebSocketProxyTunnel::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_unauthorized no 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 with ERR_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_handshake enforces verify-ca and verify-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-full with rejectUnauthorized: false), a row on the default trust store with no ca, 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_unauthorized argument of us_socket_adopt_tls is only read for server sockets. The SQL callers keep passing false there and install the client policy with set_inline_reject instead, because socket_body.rs passes the raw config value to adopt_tls while its reject policy also depends on the VM default.
  • The mysql test pauses and unshifts the raw socket before the server-side tls.TLSSocket wrap (the pattern from test/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 the close handler. That is a separate node:tls server-side quirk.
  • test/js/node/tls/node-tls-server.test.ts has 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, and test/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 …