Skip to content

Bun.listen: report socket.authorized from the client-cert verify result#33755

Open
robobun wants to merge 5 commits into
mainfrom
farm/30574f96/bun-listen-tls-authorized
Open

Bun.listen: report socket.authorized from the client-cert verify result#33755
robobun wants to merge 5 commits into
mainfrom
farm/30574f96/bun-listen-tls-authorized

Conversation

@robobun

@robobun robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

A Bun.listen TLS server created with requestCert: true reports socket.authorized === true for a client certificate signed by a CA it never trusted, while getAuthorizationError() returns a non-null error (unable to verify the first certificate) at the same time. That is the exact combination the getAuthorizationError() doc says cannot happen, and it leaves a server handing application data to a client that never proved its identity.

Fixes #33754.

Repro

const server = Bun.listen({
  hostname: "127.0.0.1",
  port: 0,
  tls: { key, cert, ca, requestCert: true }, // rejectUnauthorized defaults to true
  socket: {
    handshake(socket, success, authorizationError) {
      // socket.authorized === true, yet authorizationError is non-null
      if (!socket.authorized) socket.end();
    },
    data(socket) { socket.write("SECRET-DATA-FROM-SERVER\n"); },
  },
});
// A client presenting a cert signed by an untrusted CA still gets the response.

Cause

In NewSocket::on_handshake (src/runtime/socket/socket_body.rs), the AUTHORIZED flag was set purely from success == 1, i.e. whether the TLS handshake completed. The native verify callback (us_verify_callback) always continues the handshake and defers the verdict to the layer above, so the certificate verification result was never folded into socket.authorized. The only adjustment below that point is a hostname check gated on !is_server(), which never ran for server sockets.

Fix

Fold the BoringSSL verify result into the AUTHORIZED flag for every TLS socket, so the socket.authorized getter reflects whether the peer certificate actually verified. The flag now requires both a completed handshake and a zero verify error.

This covers the whole class, not just the Bun.listen server in the report:

  • upgradeTLS({ isServer: true }) server sockets, whose Handlers mode is deliberately Client, so a server-only gate would have missed them.
  • Bun.connect TLS clients, which had the same authorized === true + non-null getAuthorizationError() contradiction for an untrusted/self-signed server cert with a matching hostname.

The success value passed to the handshake callback is intentionally left unchanged: node:tls's server handler (ServerHandlers.handshake in src/js/node/net.ts) treats a false second argument as "the TLS session was never established" and would otherwise misreport a verify failure as a handshake failure (and drop a connection that rejectUnauthorized: false is supposed to admit). node:tls / node:https are unaffected either way: they compute their own .authorized in JS from the verify error.

Behavior change

A plain Bun.listen TLS server that never requests a client certificate ({ key, cert }, no ca, no requestCert) now reports socket.authorized === false (it was true before). No client certificate is verified, so the connection is not authorized even though the handshake completes. This matches Node's tls.TLSSocket#authorized, and resolves the pre-existing inconsistency where authorized was true while getAuthorizationError() was already non-null.

Verification

New tests in test/js/bun/net/socket.test.ts start a real Bun.listen TLS server and connect with node:tls:

  • client cert from an untrusted CA => socket.authorized === false, getAuthorizationError() non-null, and the callback's success arg stays true
  • client cert chaining to the trusted CA => socket.authorized === true, no error
  • plain server without requestCert => handshake completes (success === true) but socket.authorized === false

Fails before the change (authorized === true for the rogue cert), passes after.

On a TLS server accepting a connection, socket.authorized was computed
purely from whether the TLS handshake completed (success == 1). The
native verify callback always continues the handshake and defers the
decision, so a client certificate signed by an untrusted CA still
reported socket.authorized === true while getAuthorizationError()
returned a non-null error at the same time.

Fold the BoringSSL verify result into the AUTHORIZED flag for server
sockets so the getter matches the verification outcome. The success
value handed to the handshake callback is left unchanged: node:tls's
server handler treats a false second argument as "TLS session never
established".
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:26 AM PT - Jul 8th, 2026

@robobun, your commit 02a2433 has 5 failures in Build #70513 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33755

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

bun-33755 --bun

@github-actions

github-actions Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun.serve mTLS: rejectUnauthorized: true is non-deterministically enforced — untrusted client certs intermittently accepted #27985 - Reports the same bug: Bun.serve with mTLS non-deterministically accepts untrusted client certs because the AUTHORIZED flag was set from handshake success alone, ignoring the verify result
  2. mTLS Implementation Issues #16254 - Server-side req.socket.authorized is incorrect in node:https mTLS setups, caused by the same wrong AUTHORIZED flag computation for server sockets

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

Fixes #27985
Fixes #16254

🤖 Generated with Claude Code

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, but I'm going to hold off on the Fixes lines here. This PR only changes the native AUTHORIZED flag on the raw Bun.listen path (NewSocket::on_handshake in src/runtime/socket/socket_body.rs). The two linked issues go through different layers that this diff does not touch:

So this change shouldn't alter either of those paths. They may be worth fixing, but I'd rather verify each against its own layer than auto-close them from here.

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 19 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: be654095-f1ef-4c0f-ab92-890a688fc256

📥 Commits

Reviewing files that changed from the base of the PR and between 48838ba and 02a2433.

📒 Files selected for processing (1)
  • test/js/bun/net/socket.test.ts

Walkthrough

This PR updates TLS handshake authorization so server-side socket.authorized follows certificate verification results, and adds tests covering trusted, untrusted, and missing client-certificate cases plus an untrusted server-certificate case.

Changes

TLS client-certificate authorization fix

Layer / File(s) Summary
Authorized flag derived from verification result
src/runtime/socket/socket_body.rs
on_handshake now sets Flags::AUTHORIZED from authorized_flag, requiring handshake success and a zero SSL verification error for TLS sockets while leaving non-TLS behavior unchanged.
TLS client-certificate authorization test suite
test/js/bun/net/socket.test.ts
Adds node:tls support, TLS fixtures, a runHandshake helper, and assertions for server socket.authorized, callback/getter authorization errors, and client behavior against trusted and untrusted certificates.

Possibly related PRs

  • oven-sh/bun#29797: Also changes the TLS handshake callback path and when on_handshake is reached.
  • oven-sh/bun#31322: Also adjusts TLS authorization reporting and related handshake tests.
  • oven-sh/bun#32739: Also touches src/runtime/socket/socket_body.rs handshake behavior.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR broadens the native fix to all TLS sockets and Bun.connect client behavior, which goes beyond the Bun.listen-only linked issue. Split the TLS-wide and client-side authorization changes into a separate PR, or trim this one back to the Bun.listen server fix.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main Bun.listen authorization fix.
Description check ✅ Passed The description explains the bug, cause, fix, and verification, covering the required content despite different headings.
Linked Issues check ✅ Passed The change updates Bun.listen authorization and adds tests for untrusted and trusted client certs, matching the linked issue's core requirements.

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

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread src/runtime/socket/socket_body.rs Outdated
Comment thread test/js/bun/net/socket.test.ts
Comment thread test/js/bun/net/socket.test.ts Outdated
Broaden the fix from server sockets only to every TLS socket: the
AUTHORIZED flag now requires both a completed handshake and a zero
verify error. This also covers upgradeTLS({ isServer: true }) server
sockets (whose Handlers mode is deliberately Client) and Bun.connect
clients, which previously reported socket.authorized === true while
getAuthorizationError() returned a non-null X509 error. A plain TLS
server that never requests a client certificate now reports
authorized === false, matching node:tls's TLSSocket#authorized. The
handshake callback's success argument is unchanged.

Add tests for a plain (no requestCert) server and assert the callback
success argument stays true on a verify failure.
Comment thread test/js/bun/net/socket.test.ts
Add a client-side test: a Bun.connect TLS client to a server whose
certificate does not chain to any trusted CA (but whose hostname
matches) reports socket.authorized === false. This pins the client
branch of the verify-result fold so a future narrowing back to
server-only sockets does not pass green.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing all the review feedback — the change looks correct and well-tested now. Deferring to a human because this is TLS peer-authorization logic and it intentionally flips socket.authorized on the two most common configs (plain Bun.listen servers without requestCert, and Bun.connect clients to untrusted-but-hostname-matching servers), which is worth a maintainer sign-off.

Extended reasoning...

Overview

Two files changed. The native change is 9 lines in NewSocket::on_handshake (src/runtime/socket/socket_body.rs:1688-1698): a new local authorized_flag = authorized && ssl_error.error_no == 0 (for SSL sockets) drives Flags::AUTHORIZED, while the callback's second argument still receives the raw authorized derived from success == 1. The rest is ~250 lines of new tests in test/js/bun/net/socket.test.ts covering four cases: untrusted client cert, trusted client cert, plain server without requestCert, and a Bun.connect client to an untrusted server.

All five of my earlier inline comments were addressed across e700c4b and c0fc674: the gate was widened from handlers.mode.is_server() to all SSL sockets (covering upgradeTLS({isServer:true}) and Bun.connect clients), the no-requestCert behavior flip is now tested and called out in the PR description, server close/error handlers reject the awaited promise, result.success is asserted to lock in the callback-arg invariant, and a Bun.connect client test pins the client-side flip.

Security risks

This is a security fix — it can only turn a spurious authorized === true into false, never the reverse. The node:tls/node:https layers compute their own .authorized in JS from the callback's verifyError and are unaffected. The callback's success argument is deliberately unchanged so ServerHandlers.handshake in src/js/node/net.ts continues to distinguish "handshake failed" from "verify failed". I don't see a way this weakens any check.

Level of scrutiny

High. This is the native TLS peer-certificate authorization flag — exactly the kind of auth/crypto path the approval guidelines exclude from bot approval. Beyond that, the PR intentionally changes user-observable behavior on the two most common Bun.listen/Bun.connect TLS configurations: (1) a plain Bun.listen({tls:{key,cert}}) server now reports socket.authorized === false on every accepted connection (previously true), and (2) a Bun.connect client to a server whose cert doesn't chain to a trusted CA but whose hostname matches now reports authorized === false (previously true). Both flips are correct and match Node.js semantics, but they're default-config behavior changes that a maintainer should consciously accept.

Other factors

The 9-line native diff is mechanically simple and I'm confident it's correct. Test coverage is thorough (positive, negative, no-request, client-side). CI build #70505 was still building when I last checked the timeline. No CODEOWNERS entry covers this path.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/bun/net/socket.test.ts (1)

1944-1952: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Reject the awaited promise on pre-handshake closes.

Both promises wait for a handshake callback, but a clean close before that callback can leave the test hanging. Wire close to reject on the client paths, and reject from the helper’s Node TLS client too.

Proposed fix
     const client = nodeTls.connect({
       host: "127.0.0.1",
       port: server.port,
       ...(clientCert ? { key: clientCert.key, cert: clientCert.cert } : {}),
       ca: CA_CRT,
       servername: "localhost",
       rejectUnauthorized: false,
     });
     client.on("error", reject);
+    client.on("close", () => reject(new Error("client closed before server handshake")));
-      socket: { open() {}, data() {}, close() {}, error() {} },
+      socket: {
+        open() {},
+        data() {},
+        close() {
+          reject(new Error("server socket closed before client handshake"));
+        },
+        error(_socket, err) {
+          reject(err);
+        },
+      },
-        close() {},
+        close() {
+          reject(new Error("client socket closed before handshake"));
+        },

As per coding guidelines, “Wire EVERY failure event (error, close, abort, process exit) to reject the awaited promise.”

Also applies to: 1995-2017

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/bun/net/socket.test.ts` around lines 1944 - 1952, The TLS client
promise can hang if the socket closes before the handshake callback fires,
because only the error path currently rejects. Update the client setup around
nodeTls.connect to reject on close as well as error, and apply the same
rejection wiring in the helper used by the Node TLS client and the other
affected client paths referenced by the test block so every pre-handshake
failure settles the awaited promise.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@test/js/bun/net/socket.test.ts`:
- Around line 1944-1952: The TLS client promise can hang if the socket closes
before the handshake callback fires, because only the error path currently
rejects. Update the client setup around nodeTls.connect to reject on close as
well as error, and apply the same rejection wiring in the helper used by the
Node TLS client and the other affected client paths referenced by the test block
so every pre-handshake failure settles the awaited promise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b0947af7-6f14-4cca-976b-8694a0299702

📥 Commits

Reviewing files that changed from the base of the PR and between 62f406b and 48838ba.

📒 Files selected for processing (2)
  • src/runtime/socket/socket_body.rs
  • test/js/bun/net/socket.test.ts

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

On the out-of-scope warning: the broadening from Bun.listen-only to all TLS sockets was a deliberate response to review feedback on this PR. The initial commit gated the fix on handlers.mode.is_server(), and the review pointed out two sibling paths with the identical socket.authorized === true + non-null getAuthorizationError() contradiction: upgradeTLS({ isServer: true }) server sockets (whose Handlers mode is Client) and Bun.connect clients. They share the one root cause (the AUTHORIZED flag ignoring the verify result), so fixing them together follows the repo's fix-the-whole-class guidance rather than leaving known-broken siblings for a follow-up.

It is a net security improvement only: the flag can flip from a spurious true to false, never the reverse. node:tls/node:https are unaffected (they compute their own .authorized in JS from the verify error), and the handshake callback's success argument is intentionally unchanged. The two default-config behavior changes (plain Bun.listen server, and Bun.connect to an untrusted-but-hostname-matching server) are called out in the PR description and match Node's tls.TLSSocket#authorized. Happy to split it if a maintainer prefers the narrower landing.

Wire the server/client close and error handlers in the new TLS
authorization tests to reject the awaited promise, so a clean close
before the handshake callback fires surfaces as a failure instead of
hanging to the runner timeout.
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the failing jobs are all on the Windows 2019 x64 lane and unrelated to this diff:

  • test/js/sql/postgres-binary-array-bounds.test.ts fails with ERR_POSTGRES_CONNECTION_REFUSED (no Postgres service reachable on that agent).
  • test/cli/update_interactive_install.test.ts and test/cli/install/bun-install-registry.test.ts are flagged flaky (package-manager/registry install).

This PR only touches src/runtime/socket/socket_body.rs (TLS socket.authorized) and test/js/bun/net/socket.test.ts; none of the red jobs exercise those. The TLS authorization tests pass locally under bun bd. Ready for review/merge whenever a maintainer is happy with the scope.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior feedback has been addressed and the change looks correct to me — deferring to a human since this shifts socket.authorized semantics on every TLS socket (including the documented flip to false for plain servers and Bun.connect clients), which is worth a maintainer's explicit sign-off.

Extended reasoning...

Overview

The PR changes NewSocket::on_handshake in src/runtime/socket/socket_body.rs to fold the BoringSSL peer-certificate verify result (ssl_error.error_no == 0) into the Flags::AUTHORIZED bit that backs socket.authorized, for all TLS sockets. Previously the flag reflected only whether the TLS handshake completed. The callback's second argument is deliberately left unchanged so node:tls's server handler is unaffected. Four new tests in test/js/bun/net/socket.test.ts cover: untrusted-CA client cert → authorized=false, trusted-CA client cert → authorized=true, plain server without requestCertauthorized=false, and a Bun.connect client to an untrusted server → authorized=false.

Over two prior review rounds I raised (a) the handlers.mode.is_server() gate missing upgradeTLS({isServer:true}) and Bun.connect clients, (b) the undocumented flip for plain servers, (c) wiring server close/error to reject, (d) asserting result.success, and (e) locking in the client-side flip with a test. All five were addressed in e700c4b, c0fc674, and 02a2433; every thread is resolved and the bug hunter found nothing this run.

Security risks

This is squarely security-sensitive: it changes what socket.authorized reports on a TLS socket, which is the value user code checks to decide whether to trust a peer. The change moves the getter in the safe direction (it now reports false where it previously misreported true for unverified peers), so it does not introduce a bypass. The node:tls compat layer computes its own .authorized in JS and is unaffected. The residual risk is compat: existing Bun.listen/Bun.connect user code that read socket.authorized on a plain server or on a client to a self-signed server will now see false instead of true.

Level of scrutiny

High. The native diff is only 9 lines and I've read the surrounding on_handshake body in full — the split between authorized (passed to the callback at line 1765) and authorized_flag (drives the getter) is clean and correct. But per the approval guidelines, changes to TLS/auth semantics warrant human review regardless of size, and this one carries a documented user-visible behavior flip.

Other factors

Test coverage is solid: positive/negative server-side cases, the plain-server flip, the client-side flip, and the success === true invariant are all pinned. No CODEOWNERS entry matches these paths. I'm not approving solely because this is a security-relevant behavior change that a maintainer should explicitly ack; on the code itself I have no remaining concerns.

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.

Bun.listen() does not enforce rejectUnauthorized, and socket.authorized reports true for a certificate that failed verification

1 participant