Skip to content

tls: report a fatal TLS alert received after the handshake completes - #33294

Open
robobun wants to merge 3 commits into
mainfrom
farm/d1c2db6a/tls-post-handshake-fatal-alert
Open

tls: report a fatal TLS alert received after the handshake completes#33294
robobun wants to merge 3 commits into
mainfrom
farm/d1c2db6a/tls-post-handshake-fatal-alert

Conversation

@robobun

@robobun robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

A fatal TLS alert that arrives after the handshake has completed is silently dropped: the connection ends with a clean close and no 'error' event. Under TLS 1.3 that is exactly where a server's mTLS rejection lands, because the client finishes its handshake one flight before the server has even looked at the client certificate. A client rejected for missing or invalid credentials cannot tell that apart from an ordinary end-of-connection, so retry/failover logic keyed on why the connection ended sees flaky networking instead of "you are not authenticated".

Repro

// server: TLS1.3, requestCert + rejectUnauthorized. client: no certificate.
const s = tls.connect({ port, rejectUnauthorized: false });
s.on("secureConnect", () => log("secureConnect"));
s.on("error", e => log("error", e.code));
s.on("end", () => log("end"));
s.on("close", hadErr => log("close", hadErr));
node: ["secureConnect","error:ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED","end","close:hadError=false"]
bun:  ["secureConnect","end","close:hadError=false"]

The same hole exists on an accepted server socket: a record that fails to authenticate after the handshake completed closes the connection with no 'error', where Node reports ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC.

Cause

On a fatal SSL_ERROR_SSL inside us_internal_ssl_on_data, ssl_park_fatal_reason captures the OpenSSL reason only while the handshake is still pending, because the handshake-failure dispatch is the only thing that reads it. Once the handshake has completed there is nothing left to route the reason to, so the ERR_clear_error() inside the helper throws away TLSV1_ALERT_CERTIFICATE_REQUIRED and ssl_close reports an ordinary disconnect. Node routes the same condition through TLSWrap::ClearOut to the wrap's onerror.

Fix

  • openssl.c: when the handshake has already completed and the SSL error queue holds a reason, capture it on the stack before ssl_park_fatal_reason clears the queue, build the EPROTO verify error, and hand it to a new us_dispatch_ssl_error before closing. The JS handler may destroy the socket, so liveness is rechecked after the dispatch. The branch now also hands over any plaintext the same SSL_read loop already decrypted, because a peer can put application data and the failing record in one segment; the ZERO_RETURN sibling right above, and ClearOut, both do this.
  • uws_dispatch.rs / socket_body.rs: the dispatch only reaches bun_socket_tls sockets (same shape as us_dispatch_session / us_dispatch_keylog) and calls the socket's error handler with the decomposed error.
  • net.ts: a post-handshake EPROTO is reshaped by the existing tlsHandshakeError() into the ERR_SSL_* error and emitted rather than destroying the socket, mirroring Node's onerror, so the close that follows still delivers 'end' and 'close'.

bun_socket_tls is also the kind raw Bun.connect({ tls }) / Bun.listen({ tls }) sockets carry, and their error handler is optional. A raw-API user who never registered one now gets an uncaughtException for a post-handshake fatal alert where they previously saw only close(). That escalation is intentional: it is the same thing that happens today when their data callback throws, it matches the EventEmitter unhandled-'error' contract, and suppressing it when no handler is installed would reintroduce, for exactly those users, the silent drop this PR exists to remove.

After the fix, the event sequence matches Node exactly:

bun: ["secureConnect","error:ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED","end","close:hadError=false"]

ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED rather than Node's ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED: BoringSSL names alert 116 TLSV1_ALERT_CERTIFICATE_REQUIRED, and the rest of Bun's TLS error codes already follow BoringSSL's spelling (see test/js/node/test/common/boringssl.js and the existing ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION expectations).

node:https rides on node:tls, so it picks this up too:

node:       https error: ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED
bun (main): https error: ECONNRESET
this PR:    https error: ERR_SSL_TLSV1_ALERT_CERTIFICATE_REQUIRED

Deliberately not covered here

Two sibling sites share the pattern and are left alone on purpose:

  • tls.connect({ socket }) with a generic Duplex (and Windows named pipes) drives the handshake through SSLWrapper in src/uws/lib.rs rather than us_internal_ssl_on_data, and swallows the same alert. Wrapping a real net.Socket is covered, since that upgrades the native socket in place.
  • fetch() runs on SocketKind::HttpClientTls, which us_dispatch_ssl_error skips, so it still reports ECONNRESET. Bun's HTTP client has its own error model and no Node differential to match here.

Closing the first means adding an error channel to ssl_wrapper::Handlers, which every consumer constructs (UpgradedDuplex, WindowsNamedPipe, WindowsNamedPipeContext, ProxyTunnel, WebSocketProxyTunnel, HTTPContext), pulling the fetch and WebSocket tunnels into the blast radius. #32929 is already open against the same ERR_clear_error() sites in that file for the handshake-time variant, so it belongs there or in a follow-up rather than here.

Verification

Two tests, both failing on main and passing with this change:

  • node-tls-connect.test.ts - "reports the server's fatal alert rejecting a missing client certificate": asserts the full secureConnect / error / end / close(hadError=false) sequence plus the error's code, library and reason.
  • node-tls-server.test.ts - "reports a corrupted record on the accepted socket instead of closing cleanly": injects an unauthenticatable application_data record after the server has written its first bytes (so the handshake has provably completed) and asserts ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC.
  • node-tls-server.test.ts - "delivers data decrypted alongside a fatal record before reporting the error": splices a corrupt record onto a real client record through a plain TCP relay, so both reach the server in one recv() and the SSL_read loop decrypts the first before the second fails. Asserts ["data:ping", "error:ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC"], in that order.

The second commit fixes an unrelated hermeticity bug in a neighbouring test in the same file. "SNICallback runs even when the requested servername matches the bind hostname" binds the listener to localhost and then connects to localhost again, assuming both resolve to the same family. On a host that prefers IPv6 they do not, and the test fails before the callback can run:

listen addr: {"family":"IPv6","address":"::1","port":34067}
connect err: ECONNREFUSED 127.0.0.1

It now dials the address the listener reports and passes the bind hostname through servername, which is the property it is actually asserting. Same assertions, no resolver dependency.

Rebase notes

All three of this PR's tests were re-verified after the rebase: they fail with src/ + packages/ reverted to main and pass with the change. 151/151 node test-tls-*.js ports still pass.

Suites run against the change (all deltas vs main are zero)
  • all 151 test-tls-*.js Node ports: 151/151 pass
  • all 44 test-https-*.js Node ports: 44 pass, 1 pre-existing hang (test-https-timeout.js, hangs on main too under debug+ASAN)
  • test/js/node/tls/, test/js/node/net/, test/js/node/http2/, test/js/bun/net/, test/js/web/fetch/: failure sets identical to main (checked by stashing src/ + packages/ and rebuilding)

@github-actions github-actions Bot added the claude label Jul 3, 2026
@robobun

robobun commented Jul 3, 2026

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

@robobun, your commit 8b2182b has 2 failures in Build #71179 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33294

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

bun-33294 --bun

@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. [node:tls] Hangs on Google Cloud SQL PSC Connection Due to Unhandled Fatal Alert in TLS handshake #20727 - TLS fatal alerts during/after handshake are silently swallowed, causing hangs (e.g. Google Cloud SQL PSC connections) because the JS promise is never rejected
  2. Bun.serve mTLS: rejectUnauthorized: true is non-deterministically enforced — untrusted client certs intermittently accepted #27985 - Non-deterministic mTLS enforcement where server rejection alerts (TLS 1.3 post-handshake client cert verification) are silently dropped, making rejected connections appear successful

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

Fixes #20727
Fixes #27985

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds fatal post-handshake TLS error dispatch from OpenSSL through Rust into JS error handling. The new path reports these errors immediately, normalizes them in net.ts, and adds client and server tests for certificate rejection and corrupted TLS records.

Changes

Post-handshake TLS error reporting

Layer / File(s) Summary
OpenSSL error capture and dispatch declaration
packages/bun-usockets/src/crypto/openssl.c, packages/bun-usockets/src/internal/internal.h
us_internal_ssl_on_data now dispatches an EPROTO us_bun_verify_error_t via us_dispatch_ssl_error when a fatal SSL error occurs after handshake completion; the function is declared in internal.h.
Rust FFI shim and socket dispatch
src/runtime/socket/uws_dispatch.rs, src/runtime/socket/socket_body.rs
A us_dispatch_ssl_error shim validates the socket is TLS, forwards the error to NewSocket::on_ssl_error, and the socket handler converts it to a JS error and calls the configured error callback.
JS error normalization and wiring
src/js/node/net.ts
A new emitPostHandshakeTLSError helper detects EPROTO errors on established secure connections and emits a normalized tlsHandshakeError; wired into client, server, and Bun socket error handlers.
Client and server post-handshake TLS error tests
test/js/node/tls/node-tls-connect.test.ts, test/js/node/tls/node-tls-server.test.ts
New tests assert client behavior on a missing-client-certificate fatal alert and server behavior on a corrupted post-handshake TLS record, checking error codes and teardown.

Possibly related PRs

  • oven-sh/bun#31155: Both PRs modify the TLS fatal error capture logic in packages/bun-usockets/src/crypto/openssl.c, with this PR building the dispatch plumbing on that same capture path.
🚥 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 It clearly summarizes the main change: reporting fatal post-handshake TLS alerts.
Description check ✅ Passed It includes the PR purpose and verification details, though not under the exact template headings.

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

@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

I checked both before adding the Fixes lines. Neither one is this bug, so I'm leaving them off.

#20727 is a fatal alert during the handshake (unexpected_message on the ClientHello). That path already reports the alert on current main; a client forced into a version mismatch gets the real code rather than hanging:

# server maxVersion TLSv1.2, client minVersion TLSv1.3
node:        error: ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION
bun 1.4.0:   error: ERR_SSL_TLSV1_ALERT_PROTOCOL_VERSION

The handshake-time capture landed in #31155, well after the 1.2.17 that issue was filed against. This PR covers the post-handshake variant, which is adjacent but not what #20727 describes, and that issue also runs through @google-cloud/cloud-sql-connector, which I can't reproduce here. Better not to auto-close it.

#27985 is Bun.serve non-deterministically accepting a client cert signed by an untrusted CA. That is a verification-enforcement problem in the uWS server path, not a swallowed alert, and it no longer reproduces on main:

# 30 requests with a rogue client cert against Bun.serve { requestCert, rejectUnauthorized }
bun 1.4.0 (main):  accepted=0 rejected=30 {"ECONNRESET":30}
this PR:           accepted=0 rejected=30 {"ECONNRESET":30}

Identical with and without the change, because the fetch() client runs on SocketKind::HttpClientTls, which us_dispatch_ssl_error skips by design (see "Deliberately not covered here" in the description).

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread packages/bun-usockets/src/crypto/openssl.c
Comment thread src/runtime/socket/uws_dispatch.rs
@robobun

robobun commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

CI status

Build #71179 on the rebased head 8b2182b73b: 281/286 jobs passed. The 3 red lanes are unrelated to this PR.

This PR's two test files ran on every test-bun lane and passed on all of them; neither node-tls-connect.test.ts nor node-tls-server.test.ts appears in any failure annotation.

What failed, and why it is not mine:

lane test shape seen on other branches
windows 2019 x64, windows 2019 x64-baseline test/js/sql/postgres-binary-array-bounds.test.ts ERR_POSTGRES_CONNECTION_REFUSED (Postgres service unreachable on the runner) 7 of the 20 builds before mine, e.g. #71174 node-http-te-framing, #71173 h2-decoder-header-table-size, #71172 ciro/repl-node-tests, #71169 lockb-bounds
darwin 14 x64 test/js/bun/http/proxy-stress-concurrent.test.ts 1 of 1200 fetch() requests through an https proxy failed; stress test 8 of the 25 builds nearest mine, e.g. #71190 napi-external-string-env-ref, #71189 ciro/worker-threads-compat, #71184 make-more-tests-faster
darwin 14 x64 bun run --no-orphans (perl): fast-exit intermediate process-reaping timeout; no TLS (retried, no annotation)

The proxy stress test is fetch()-only (SocketKind::HttpClientTls), which the new us_dispatch_ssl_error explicitly skips (if s_ref.kind() != SocketKind::BunSocketTls { return; }), so there is no route from this diff to it even in principle.

No retrigger. Both remaining failures are hitting ~30% of builds across branches right now; a re-roll would reproduce them.

The diff is green and ready for review.

Local verification at 8b2182b73b (post-rebase)

Gate procedure, reverting src/ + packages/ to main and rebuilding:

  • without the fix: 3 fail (exactly the three new tests)
  • with the fix: 0 fail, 59 pass in the two gate files

Regression runs:

  • 151/151 test-tls-*.js Node ports
  • test/js/node/tls/: 2 pre-existing container-environment failures, identical to main
Previous build #68173 (pre-rebase)

285/286 jobs passed. The one red lane was darwin: 14 x64 - test-bun timing out after 90s on test/js/bun/terminal/terminal.test.ts "creates subprocess with terminal attached", a PTY spawn test with no TLS in it. Build #68166 on branch claude/webstreams-cpp failed the identical test on the identical lane with the same 90s timeout and the same 88 pass 1 todo 1 fail shape.

robobun added 3 commits July 9, 2026 21:50
us_internal_ssl_on_data parked the OpenSSL reason for a fatal SSL error
only while the handshake was still pending, so an alert that arrives
afterwards was dropped and the connection closed with no 'error' event.
Under TLS 1.3 that is exactly where a server's mTLS rejection lands,
because the client finishes its handshake one flight before the server
has validated the client certificate, which left an authentication
failure indistinguishable from a clean end-of-connection.

Dispatch the reason to the socket's JS error handler before the close,
the way Node's TLSWrap onerror does, and shape it into the ERR_SSL_*
error in node:net. Covers the client (certificate_required) and accepted
server sockets (bad record MAC).
The test bound the listener to "localhost" and then connected to
"localhost" again, assuming both resolve to the same family. On a
dual-stack host listen() picks ::1 while connect() picks 127.0.0.1 and
the connection is refused before the SNICallback can run.

Dial the address the listener reports and pass the bind hostname through
`servername`, which is the property the test is actually asserting.
The SSL_read loop accumulates decrypted bytes across iterations, and the
fatal-error exit was the only one that never handed them to the consumer:
a peer that writes application data and the failing record into one
segment lost the data. The ZERO_RETURN sibling right above, and Node's
ClearOut, both dispatch what was decrypted before reporting.
@robobun
robobun force-pushed the farm/d1c2db6a/tls-post-handshake-fatal-alert branch from 92a3217 to 8b2182b Compare July 9, 2026 21:51
robobun added a commit that referenced this pull request Jul 10, 2026
…alert

The 'Fail to complete client's chain' comment described the pre-change
behaviour (Bun aborts without an alert). The server now sends the fatal
alert like Node; fetch still surfaces ECONNRESET over TLS 1.3 because the
alert arrives post-handshake (#33294), so the assertion is unchanged.
robobun added a commit that referenced this pull request Jul 15, 2026
…alert

The 'Fail to complete client's chain' comment described the pre-change
behaviour (Bun aborts without an alert). The server now sends the fatal
alert like Node; fetch still surfaces ECONNRESET over TLS 1.3 because the
alert arrives post-handshake (#33294), so the assertion is unchanged.
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