Skip to content

ws: forward top-level TLS options (rejectUnauthorized, ca, ...) to the connection#31397

Open
robobun wants to merge 11 commits into
mainfrom
farm/3cdf7917/ws-top-level-tls-options
Open

ws: forward top-level TLS options (rejectUnauthorized, ca, ...) to the connection#31397
robobun wants to merge 11 commits into
mainfrom
farm/3cdf7917/ws-top-level-tls-options

Conversation

@robobun

@robobun robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator

What

The npm ws package accepts TLS options as top-level options on the WebSocket client constructor and forwards them to the underlying https.request/tls.connect:

new WebSocket("wss://localhost:8443", { rejectUnauthorized: false });

Bun's ws shim (src/js/thirdparty/ws.js) only read TLS options from options.tls (Bun's internal shape) or from an agent. It never read the top-level keys, so rejectUnauthorized: false was silently dropped and connecting to a self-signed wss:// server failed with TLS handshake failed.

Fixes #31396.

Reproduction

import WebSocket from "ws";
// wss:// server with a self-signed cert
const ws = new WebSocket(`wss://localhost:${port}`, { rejectUnauthorized: false });
ws.on("open", () => console.log("open"));   // never fired
ws.on("error", err => console.error(err.message)); // "TLS handshake failed"

Node connects; Bun failed. Passing the same option via Bun's tls: { rejectUnauthorized: false } shape already worked — proving the native TLS layer was fine and the bug was purely option plumbing in the shim.

Cause

In the constructor, TLS was read only from options.tls:

tlsOptions = options?.tls;

Top-level rejectUnauthorized/ca/cert/key/… were never consulted.

Fix

When no explicit options.tls object is given, collect the TLS keys the native WebSocket understands (the set SSLConfig.fromJS parses) off the top-level options:

tlsOptions = $isObject(options.tls) ? options.tls : extractTlsOptions(options);
  • An explicit options.tls stays authoritative, preserving Bun's existing shape.
  • Booleans (rejectUnauthorized, …) are forwarded with a !== undefined check so an explicit false is honored.
  • The agent path (HttpsProxyAgent, …) keeps its conservative key subset — agents put connection options in connectOpts that aren't all valid Bun TLS options (e.g. an array-form ALPNProtocols, which SSLConfig.fromJS rejects).

Verification

New tests in test/js/first_party/ws/ws-tls-options.test.ts (alongside the existing ws-proxy.test.ts) stand up a self-signed wss:// server via Bun.serve({ tls }) and connect through the ws shim:

  • rejectUnauthorized: false connects (fails before this change, passes after).
  • Without the option, the self-signed server is still rejected (the option is actually doing something).
  • An agent carrying ca plus a top-level servername connects — both TLS sources reach the handshake, covering the merge path.

The ws and ws-proxy suites are otherwise unchanged from their baseline.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:59 PM PT - Jun 20th, 2026

@robobun, your commit 2111ced has 2 failures in Build #63719 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31397

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

bun-31397 --bun

@coderabbitai

coderabbitai Bot commented May 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Centralizes TLS option extraction and whitelisting, applies it to agent option extraction, updates BunWebSocket to use explicit options.tls or derive from top-level TLS fields and to merge agent TLS when appropriate, and adds tests covering self-signed and merge behaviors.

Changes

WebSocket TLS options support

Layer / File(s) Summary
TLS extraction helpers and agent rewiring
src/js/thirdparty/ws.js
Adds tlsBooleanKeys, tlsScalarKeys, tlsFileKeys, tlsValueKeys, agentTlsValueKeys and extractTlsOptions(source, valueKeys); rewires extractAgentOptions to use the helper for agent tls extraction.
BunWebSocket constructor TLS initialization
src/js/thirdparty/ws.js
Constructor treats options.tls as authoritative only when it's an object; otherwise derives tlsOptions from top-level TLS fields using extractTlsOptions(options).
Agent TLS merging with top-level options
src/js/thirdparty/ws.js
If options.tls was not an explicit object, merges agentOpts.tls into existing tlsOptions, with top-level TLS keys winning conflicts.
TLS validation tests
test/js/first_party/ws/ws-tls-options.test.ts
Adds a test suite that starts a self-signed TLS WebSocket server and verifies: top-level rejectUnauthorized: false allows connection; omission causes TLS handshake failure; agent connectOpts CA and top-level servername both apply; ALPNProtocols as string[] is accepted; explicit tls: {} prevents agent TLS from overriding.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: forwarding top-level TLS options to the WebSocket connection, matching the core purpose of the PR.
Description check ✅ Passed The description provides comprehensive context including what was changed, how it was verified, and the reproduction case, though it uses a non-standard two-section format rather than the required template.
Linked Issues check ✅ Passed The PR fully addresses issue #31396 by implementing TLS option forwarding to allow connections to self-signed wss:// servers with top-level { rejectUnauthorized: false } option, with tests verifying the fix works.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing TLS option forwarding in the ws shim and adding targeted tests; no unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Comment thread src/js/thirdparty/ws.js Outdated
Comment thread test/js/first_party/ws/ws-tls-options.test.ts
Comment thread src/js/thirdparty/ws.js Outdated
Comment thread src/js/thirdparty/ws.js Outdated

@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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@src/js/thirdparty/ws.js`:
- Around line 193-194: The bug is that extractTlsOptions drops explicit falsy
TLS scalar values so keys like secureOptions, clientRenegotiationLimit,
clientRenegotiationWindow, and passphrase are lost when options.tls is absent;
update extractTlsOptions to copy a key when it exists on the source (e.g., using
Object.prototype.hasOwnProperty.call(source, key) or key in source) instead of
checking truthiness, so tlsValueKeys are preserved; confirm tlsOptions
assignment (the code around extractTlsOptions, tlsValueKeys, options.tls,
tlsOptions and the merge { ...agentOpts.tls, ...tlsOptions }) will now allow
falsy values to override agentOpts.tls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d9b435cf-6ae2-45fb-a46b-f996f579ad3e

📥 Commits

Reviewing files that changed from the base of the PR and between ecc9ee5 and 3aaf125.

📒 Files selected for processing (2)
  • src/js/thirdparty/ws.js
  • test/js/first_party/ws/ws-tls-options.test.ts

Comment thread src/js/thirdparty/ws.js
Comment thread src/js/thirdparty/ws.js Outdated

@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 earlier nits are addressed and I have nothing further — deferring only because the precedence rules around rejectUnauthorized / agent-vs-top-level merging are TLS-verification semantics that deserve a human sign-off.

Extended reasoning...

Overview

This PR changes src/js/thirdparty/ws.js (Bun's npm ws compat shim) to forward top-level TLS options (rejectUnauthorized, ca, cert, key, …) on the WebSocket client constructor into the native WebSocket's tls option, matching how the real npm ws package forwards them to https.request/tls.connect. It adds a key-whitelist extractor (extractTlsOptions) split into boolean/scalar/file buckets, an isForwardableFileValue guard for shapes SSLConfig.fromJS can't parse, and a merge step that combines agent-supplied TLS with top-level keys unless an explicit options.tls object was passed. A new test file (test/js/first_party/ws/ws-tls-options.test.ts, ~143 lines, 6 cases) covers the primary fix plus each edge case raised in review.

Security risks

The change directly governs when TLS certificate verification is enabled for a wss:// connection. The interesting surface is the precedence between three sources — explicit options.tls, extracted top-level keys, and agent.connectOpts — and specifically whether an agent's rejectUnauthorized: false (semantically aimed at the proxy hop) can leak into the target handshake. That leak existed in an intermediate commit and was fixed in 3aaf125 (guarded on !explicitTls) with a test asserting the agent's value cannot disable verification when tls: {} is passed. I don't see a remaining path that weakens verification relative to pre-PR behavior; the net effect is that users can now turn it off via the Node-canonical top-level option, which is the intended compat fix.

Level of scrutiny

Medium-high. The implementation is option plumbing in a JS shim (no native code touched), but it controls TLS verification and the merge/override rules took five review iterations to settle (agent merge vs replace, ALPNProtocols string[] throw, explicit-tls authoritativeness, falsy scalar preservation, key: [{pem,passphrase}] object-array throw). Each was addressed with a targeted regression test, and the bug-hunting pass on b390313 found nothing new. Still, the subtlety of the precedence semantics is exactly the kind of thing a maintainer should glance at rather than auto-merge.

Other factors

No CODEOWNERS entry for src/js/thirdparty/. The single CI failure (test/bundler/transpiler/transpiler.test.js on Windows) is unrelated to this change. All prior inline comments — mine and CodeRabbit's — are resolved. I have not previously posted a review body on this PR, only inline nits, so this deferral is not redundant.

@robobun

robobun commented May 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI is green on every lane except windows-2019-x64-test-bun, which failed in isolation. The sibling Windows lane (windows-11-aarch64-test-bun) and all six Linux lanes (debian/ubuntu × x64/aarch64 + baselines) pass on this same commit (b390313).

This change is JS-only (src/js/thirdparty/ws.js + a TS test) with no platform-specific code paths, so a failure isolated to one of the two Windows lanes is unrelated flake rather than a real regression. The diff itself is green. Flagging for a maintainer to re-run that lane / merge.

@robobun robobun force-pushed the farm/3cdf7917/ws-top-level-tls-options branch from b390313 to 64975c0 Compare May 27, 2026 13:48
@robobun

robobun commented May 27, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main and force-pushed.

The previous red on this PR was entirely the flaky Windows Bun.Transpiler...stack overflows tests in test/bundler/transpiler/transpiler.test.js — all three Windows lanes, with zero ws/TLS involvement. That test's fix landed on main in #31382 ("Deepen the lots-of-for-loop fixture so the transpiler stack-overflow tests throw on Windows"), but this branch was 21 commits behind and predated it. Rather than re-roll the same flake, I rebased so the branch picks up that fixture fix.

  • Rebase was conflict-free (this branch only touches src/js/thirdparty/ws.js + test/js/first_party/ws/ws-tls-options.test.ts; none of the incoming commits touch ws.js).
  • The ws TLS diff is unchanged (145 lines in ws.js, 143-line test).
  • Re-verified locally on the merged tree with a debug build: ws-tls-options.test.ts passes 6/6.

New CI run is in flight against the rebased tree.

Comment thread src/js/thirdparty/ws.js

@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 six rounds of prior feedback are addressed and the test coverage is solid — I don't see anything left to flag, but since this is TLS-verification plumbing (precedence around rejectUnauthorized/ca between top-level, agent, and explicit tls), I'd like a human to sign off rather than auto-approve.

Extended reasoning...

Overview

This PR changes src/js/thirdparty/ws.js so Bun's ws shim forwards top-level TLS options (rejectUnauthorized, ca, cert, key, servername, …) to the native WebSocket's tls option, matching how npm ws forwards them to https.request/tls.connect. It adds ~120 lines of new logic: three key whitelists (tlsBooleanKeys, tlsScalarKeys, tlsFileKeys), an extractTlsOptions helper with per-bucket falsy-handling, an isForwardableFileValue/isRepresentableFileElement shape guard, and merge-precedence rules between top-level options, agent connectOpts, and an explicit Bun tls:{} object. A new 7-case test file exercises the happy path, the negative path, the agent-merge path, the explicit-override path, and three no-op→throw guards.

Security risks

The change is squarely in TLS-verification territory. It does not weaken any default — it forwards user-supplied options that were previously dropped — but the precedence logic determines whether rejectUnauthorized: false from one source (e.g. an agent's proxy-hop connectOpts) can leak into the target handshake. One of my earlier review rounds (3aaf125) caught and fixed exactly that leak, and the explicitTls guard plus the "keeps an explicit tls object authoritative" test now cover it. I don't see a remaining vector, but the consequence of a precedence mistake here is silently disabled certificate verification, which is why I'm not auto-approving.

Level of scrutiny

Medium-high. This is JS-only option plumbing (no native/crypto changes), but it took six review iterations to converge — each round surfaced a real edge (agent-vs-top-level shadowing, ALPNProtocols string[] throwing, explicit-tls override leak, falsy-scalar handling, {pem,passphrase} array and bare-object forms throwing). That history says the surface is subtle enough that a maintainer familiar with SSLConfig.fromJS and the agent/proxy semantics should give it a final read.

Other factors

All inline comments from me and CodeRabbit are resolved; the bug-hunting system found nothing on e57956c. Test coverage is good and each case maps to a specific review round. The robobun CI-status comment still references an older commit (64975c0) with a failure, so the latest commit's CI state isn't visible in the thread yet — worth confirming green before merge. No CODEOWNERS entry covers this path.

robobun and others added 11 commits June 21, 2026 04:02
…e connection

The npm ws package accepts TLS options as top-level options on the
WebSocket constructor and forwards them to https.request/tls.connect:

  new WebSocket("wss://host", { rejectUnauthorized: false });

Bun's ws shim only read TLS options from options.tls, so top-level keys
were silently dropped and connecting to a self-signed wss:// server
failed with "TLS handshake failed".

Collect the TLS keys the native WebSocket understands (the set
SSLConfig.fromJS parses) off the top-level options when no explicit
options.tls object is given. An explicit options.tls stays authoritative
for back-compat with Bun's existing shape. The agent path keeps its
conservative key subset, since agents like HttpsProxyAgent put connection
options in connectOpts that aren't all valid Bun TLS options.
The ws.test.ts suite spawns a subprocess echo server for most cases,
which times out under the debug/ASAN build and drowns out the new
regression test. Move it to test/regression/issue/31396.test.ts, which
is self-contained (Bun.serve + the ws shim, no subprocess), so the
rejectUnauthorized case fails cleanly without the fix and passes with it.
A single top-level TLS key made tlsOptions truthy, which skipped the
agent fallback entirely and dropped the agent's ca/cert/key/passphrase.
In ws, top-level TLS options and the agent's connect options both reach
the connection, so merge them (top-level wins on conflict, agent fills
gaps) instead of replacing one with the other.
Issue #31396 is a never-supported feature (top-level TLS options on the
ws client), not a regression that worked in a prior release, so per the
test-layout convention it doesn't belong in test/regression/issue/.
Move it next to the existing ws-proxy.test.ts in test/js/first_party/ws/,
keeping it in its own file so the debug/ASAN subprocess timeouts in
ws.test.ts don't drown out the signal.
Node/ws accept ALPNProtocols as a string[], but Bun's TLS option parser
(SSLConfig.fromJS) only accepts string/ArrayBuffer/null and throws a
TypeError on an array. Including ALPNProtocols in the top-level key set
turned a previously-ignored option into a synchronous constructor throw.

Drop it from the top-level key set (the agent path already excluded it
for the same reason) so it stays a no-op. WebSocket negotiates
subprotocols via Sec-WebSocket-Protocol, not TLS ALPN, so this has no
practical downside. Adds a test that a string[] ALPNProtocols connects
instead of throwing.
The agent-merge added earlier also ran when tlsOptions came from an
explicit options.tls object, so it was no longer a hard override: an
agent's rejectUnauthorized:false (intended for the proxy hop) could leak
into a user-supplied tls:{ca} and disable target verification, where
pre-change the agent was ignored entirely.

Guard the merge on !explicitTls so an explicit tls object stays a true
override (matching the documented behavior), while the merge still
applies to the extracted top-level path it was meant to fix. Adds a test
that an agent's rejectUnauthorized:false does not leak into an explicit
tls object.
…absent

extractTlsOptions copied top-level value keys only when truthy, so an
explicit falsy scalar (e.g. passphrase: "", secureOptions: 0) was dropped
where options.tls would have forwarded it. Split the value keys: scalar
keys now forward on != null (consistent with the boolean keys and with
options.tls), while file/identity keys (ca, cert, key, caFile, servername,
ciphers, ...) stay on a truthy check — an empty string there means absent,
and forwarding e.g. ca: "" makes the native parser treat it as a real
empty value and breaks the handshake.
Node/ws accept key/cert as an array of { pem, passphrase } objects
(per-key passphrases), but Bun's native TLS parser only understands
string/ArrayBuffer/Blob or arrays of those. Forwarding the object-array
form turned a previously-ignored option into a constructor TypeError.

key/cert can't be dropped (the string/Buffer forms are the core of this
PR), so skip only the object-array shape via isForwardableFileValue — it
falls back to the pre-PR no-op instead of throwing, while the
string/Buffer/string[]/Blob forms still forward. Adds a test that an
object-array key constructs without throwing.
…ed one

isForwardableFileValue only applied the plain-object guard to array
elements, so a bare `key: { pem, passphrase }` (or a KeyObject) fell
through and was forwarded to SSLConfig.fromJS, which throws — asymmetric
with `key: [{ pem }]`, which was correctly skipped, and contrary to the
function's own contract.

Factor the shape check into isRepresentableFileElement and apply it to
the bare value too, so both the wrapped and unwrapped object forms stay a
no-op instead of throwing at construction. Adds a test for the bare form.
@robobun robobun force-pushed the farm/3cdf7917/ws-top-level-tls-options branch from e57956c to 2111ced Compare June 21, 2026 04:07
@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto latest main and force-pushed to clear the merge conflicts.

The only conflicts were in src/js/thirdparty/ws.js, from #32522 ("lint(src/js): flag duplicate property reads between an if condition and its body"), which refactored the same regions my commits touch:

The ws TLS diff is otherwise unchanged. Verified on the rebased tree: ws-tls-options.test.ts passes 7/7, ws-proxy.test.ts at its baseline (16 pass, 3 pre-existing unrelated failures), and audited the new code against the new oxlint rule (my if conditions read only locals or computed source[key], neither of which the rule flags).

@robobun

robobun commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

The only red on the latest run (build 63719, sha 2111ced) is the Lint JavaScript GitHub Actions job, which failed with:

$ oxlint --config=oxlint.json --format=github src/js
/usr/bin/bash: line 1: oxlint: command not found
error: script "lint" exited with code 127

That's a runner-provisioning hiccup (the oxlint binary wasn't on PATH), not a lint violation in this diff. Unrelated PR #32549 hit the identical oxlint: command not found failure in the same ~2-minute window; PRs that ran at other times passed lint. All BuildKite test lanes that have completed are green (windows-11-aarch64, debian-13-x64-asan, musl builds), with zero test failures.

The ws TLS code is lint-clean (its if conditions read only locals or computed source[key], neither of which the new bun/no-duplicate-conditional-property-access rule flags). Re-running the Lint JavaScript job once oxlint is provisioned should clear it. Flagging for a maintainer since a re-run needs a human / infra, not a code change.

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.

WebSocket npm fails TLS handshake with self signed certificates

1 participant