Skip to content

fetch: reject bad client tls material with the BoringSSL reason instead of FailedToOpenSocket - #36152

Closed
robobun wants to merge 5 commits into
mainfrom
claude/farm/6286c5d8/fetch-tls-material-errors
Closed

robobun wants to merge 5 commits into
mainfrom
claude/farm/6286c5d8/fetch-tls-material-errors

Conversation

@robobun

@robobun robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

fetch(url, { tls: { cert, key, passphrase } }) with a bad client identity (cert/key mismatch, wrong passphrase, a file path passed where PEM contents are expected) rejected with the generic connection error:

{ code: "FailedToOpenSocket", message: "Was there a typo in the url or port?" }

All three failure modes were indistinguishable, had no OpenSSL code, and the message pointed the user at the URL when the problem was their cert material.

Reproduction

using srv = Bun.serve({ port: 0, tls: serverCert, fetch: () => new Response("ok") });
for (const [name, tls] of [
  ["mismatch",   { cert: certA, key: keyB }],
  ["wrong-pass", { cert: certA, key: encryptedKeyA, passphrase: "wrong" }],
  ["junk-pem",   { cert: "not a pem", key: keyA }],
  ["control",    { cert: certA, key: keyA }],
])
  console.log(name, await fetch(srv.url, { tls: { ...tls, rejectUnauthorized: false } })
    .then(r => r.text(), e => ({ code: e.code, reason: e.reason })));

Before:

mismatch   { code: "FailedToOpenSocket", reason: undefined }
wrong-pass { code: "FailedToOpenSocket", reason: undefined }
junk-pem   { code: "FailedToOpenSocket", reason: undefined }
control    "ok"

After:

mismatch   { code: "ERR_OSSL_X509_KEY_VALUES_MISMATCH", reason: "KEY_VALUES_MISMATCH" }
wrong-pass { code: "ERR_OSSL_BAD_DECRYPT",              reason: "BAD_DECRYPT" }
junk-pem   { code: "ERR_OSSL_PEM_NO_START_LINE",        reason: "NO_START_LINE" }
control    "ok"

(Node with OpenSSL reports the second as ERR_OSSL_EVP_BAD_DECRYPT; BoringSSL attributes it to ERR_LIB_CIPHER, which is not in Node's library-prefix map, so the code has no library segment. The library/reason fields still identify it.)

Cause

The client SSL_CTX for a custom tls config is built lazily on the HTTP thread (HTTPContext::init_with_client_config -> init_with_opts -> C us_ssl_ctx_build_raw). When cert or key parsing fails, every branch does ssl_ctx_build_fail(); return NULL; without setting the *err out-param, so it stays CREATE_BUN_SOCKET_ERROR_NONE; the real reason sits on the thread-local BoringSSL error queue. init_with_opts maps none to InitError::FailedToOpenSocket, HTTPThread maps that to http::Error::FailedToOpenSocket, and FetchTasklet renders the "typo" message. There is no ERR_get_error anywhere in src/http/.

node:tls SecureContext::create_private calls the same create_ssl_context, but on err == none routes through create_bun_socket_error_to_js, whose .none arm already calls err_to_js(global, ERR_get_error()) and decorates the error with Node's code/library/reason shape.

Fix

In fetch.rs, right after SSLConfig::from_js returns a config that would require a custom SSL context (requires_custom_request_ctx), build the SSL_CTX eagerly on the JS thread. On None, throw via create_bun_socket_error_to_js(err, global) (the same helper node:tls and Bun.listen use) so the rejection carries the BoringSSL code/library/reason and a real stack. On success, release the probe context; the HTTP thread builds its own from the interned config as before. Guarding on requires_custom_request_ctx means { rejectUnauthorized: false } and other option-only configs incur no extra build.

Testing

Four new cases in test/js/web/fetch/fetch.tls.test.ts using the existing Node key fixtures: cert/key mismatch, wrong passphrase on an encrypted key, non-PEM cert string, and a control with matching material. The first three fail on main with FailedToOpenSocket and pass with distinct ERR_OSSL_* codes after the fix; the control passes on both.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/web/fetch/fetch.tls.test.ts
bun test v1.4.0 (2651349dc)

test/js/web/fetch/fetch.tls.test.ts:
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity that throws should reject [1163.43ms]
(pass) fetch-tls > fetch with valid tls should not throw [1809.47ms]
(pass) fetch-tls > can handle multiple requests with non native checkServerIdentity [1874.12ms]
(pass) fetch-tls > fetch with rejectUnauthorized: false should not call checkServerIdentity [315.71ms]
(pass) fetch-tls > re-derives the Host header and TLS verification hostname from the redirect target on a cross-origin redirect [2219.04ms]
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity should work [2168.99ms]
(pass) fetch-tls > fetch with self-sign tls should throw [170.64ms]
(pass) fetch-tls > fetch with invalid tls should throw [165.85ms]
(pass) fetch-tls > fetch with checkServerIdentity failing should throw [284.45ms]
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers [685.72m
... (truncated)

release without fix: 4 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/web/fetch/fetch.tls.test.ts:
(pass) fetch-tls > fetch with valid tls should not throw [1540.90ms]
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers [42.53ms]
(pass) fetch-tls > checkServerIdentity approval still transmits the request and round-trips the response [39.95ms]
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity should work [1560.57ms]
(pass) fetch-tls > fetch should use NODE_EXTRA_CA_CERTS [41.31ms]
729 |     }
730 | 
731 |     it("cert/key mismatch rejects with ERR_OSSL_X509_KEY_VALUES_MISMATCH", async () => {
732 |       const err = await rejection({ cert: agent1Cert, key: agent2Key });
733 |       expect(err).toBeInstanceOf(Error);
734 |       expect({ code: err.code, library: err.library, reason: err.reason }).toEqual({
                                                                                 ^
error: expect(received).toEqual(expected)

  {
-   "code": "ERR_OSSL_X509_KEY_VALUES_MISMATCH",
-   "library": "X.509 certificate routines",
-   "reason": "KEY_VALUES_MISMATCH",
+   "code": "FailedToOpenSocket",
+   "library": unde
... (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/mechgate.xml" test/js/web/fetch/fetch.tls.test.ts
bun test v1.4.0 (2651349dc)

test/js/web/fetch/fetch.tls.test.ts:
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity that throws should reject [1149.04ms]
(pass) fetch-tls > fetch with valid tls should not throw [1789.31ms]
(pass) fetch-tls > can handle multiple requests with non native checkServerIdentity [1850.20ms]
(pass) fetch-tls > fetch with rejectUnauthorized: false should not call checkServerIdentity [310.62ms]
(pass) fetch-tls > re-derives the Host header and TLS verification hostname from the redirect target on a cross-origin redirect [2193.23ms]
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity should work [2144.99ms]
(pass) fetch-tls > fetch with self-sign tls should throw [166.24ms]
(pass) fetch-tls > fetch with invalid tls should throw [161.03ms]
(pass) fetch-tls > fetch with checkServerIdentity failing should throw [281.50ms]
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers [676.25m
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 753ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/6] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[1/6] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_output v
... (truncated)
diff hotspot
src/runtime/webcore/fetch.rs        | 29 ++++++++++++++
 test/js/web/fetch/fetch.tls.test.ts | 77 +++++++++++++++++++++++++++++++++++++
 2 files changed, 106 insertions(+)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                 reads  edits  tests
src/runtime/webcore/fetch.rs             7      6      0
test/js/web/fetch/fetch.tls.test.ts      2      2      0

…ad of 'Was there a typo in the url or port?'

When fetch({tls:{cert,key,passphrase}}) carried a bad client identity (cert/key
mismatch, wrong passphrase, non-PEM string), the rejection was always
{ code: 'FailedToOpenSocket', message: 'Was there a typo in the url or port?' }.

The SSL_CTX for a custom tls config is built lazily on the HTTP thread.
us_ssl_ctx_build_raw returns NULL for bad cert/key without setting *err (the
detail lives on the thread-local BoringSSL error queue), and HTTPContext maps
err==none to FailedToOpenSocket without reading the queue. node:tls
createSecureContext already routes the same NULL+none case through
create_bun_socket_error_to_js, which pops ERR_get_error() and builds a
Node-shaped error with code/library/reason.

Build the SSL_CTX eagerly on the JS thread when the config would require a
custom context, and route failure through the same helper. Bad material now
rejects with ERR_OSSL_X509_KEY_VALUES_MISMATCH / ERR_OSSL_BAD_DECRYPT /
ERR_OSSL_PEM_NO_START_LINE, matching node:https.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

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

Next review available in: 5 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: 866ebd63-c1e7-4507-b10a-fe0d46969b41

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and 2651349.

📒 Files selected for processing (2)
  • src/runtime/webcore/fetch.rs
  • test/js/web/fetch/fetch.tls.test.ts

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

@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: diff is green; waiting on a maintainer to pick between this and #36149.

Reproduced on 1.4.0 and current main:

$ bun repro.ts
mismatch   {"code":"FailedToOpenSocket","message":"Was there a typo in the url or port?"}
wrong-pass {"code":"FailedToOpenSocket","message":"Was there a typo in the url or port?"}
junk-pem   {"code":"FailedToOpenSocket","message":"Was there a typo in the url or port?"}
control    "ok"

Fail-before/after on the new tests:

$ USE_SYSTEM_BUN=1 bun test test/js/web/fetch/fetch.tls.test.ts -t "client tls material"
 1 pass  3 fail   # all three collapse to FailedToOpenSocket; control passes

$ bun bd test test/js/web/fetch/fetch.tls.test.ts -t "client tls material"
 4 pass  0 fail

CI on 2651349 (build 83648): fetch.tls.test.ts and fetch-tls-cert.test.ts pass on every lane. Remaining failures are unrelated: serve.test.ts "request body backpressure" EPIPE on darwin 14 aarch64 (same test is flaky on darwin 14 x64 in this build), plus complex-workspace / webview-chrome / require-cache / 20144 which all passed on retry.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fetch(tls): surface ERR_OSSL_* for bad client cert/key instead of FailedToOpenSocket #36149 - Same fix: surfaces ERR_OSSL_* codes for bad client cert/key instead of FailedToOpenSocket. Same test file (fetch.tls.test.ts), same error codes, same before/after behavior. Differs only in implementation approach (HTTP-thread capture vs JS-thread eager probe).

🤖 Generated with Claude Code

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

#36149 fixes the same bug with a different approach: it captures ERR_get_error() on the HTTP thread inside init_with_opts and carries the packed code through new InitError::ClientTLSSetup(u32) / http::Error::ClientTLSSetup(u32) variants to FetchTasklet::on_reject.

Trade-off between the two:

this PR (eager probe on JS thread) #36149 (capture on HTTP thread)
src touched 1 file, reuses create_bun_socket_error_to_js 7 files, new enum variants + formatter helper
error shape full err_to_js decoration: code / message / library / reason / function, plus a real caller stack SystemError with code / message / path
runtime cost one extra SSL_CTX build+free on the JS thread per fetch that sets requires_custom_request_ctx (cert/key/ca/ciphers/...) none
covers ca / ciphers / ecdhCurve yes (routes every enum arm through the helper) ciphers/ecdhCurve yes; ca still collapses to FailedToOpenSocket

Either is mergeable on its own; leaving both open for a maintainer to pick.

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

No bugs found, but deferring: this touches the client TLS setup path and makes a design trade-off (probe-build the SSL_CTX on the JS thread, free it, then rebuild on the HTTP thread) that a maintainer should sign off on — in particular, the probe fires on every fetch() with requires_custom_request_ctx, even when the HTTP-thread ctx cache would have hit.

What was reviewed:

  • Error-path ownership: SSLConfig has Drop, so config is released on the None arm; the Some arm's +1 SSL_CTX is freed via SSL_CTX_free.
  • create_bun_socket_error_to_js's .none arm reads ERR_get_error() on the same thread that just failed the build, so the queue is populated.
  • Err(throw_value(...)) flows through reject_on_exception into a rejected promise, so fetch() still never throws synchronously.
  • Test fixtures (agent1-cert.pem, agent2-key.pem, rsa_private_encrypted.pem, rsa_cert.crt) all exist under test/js/node/test/fixtures/keys/.
Extended reasoning...

Overview

Adds an eager SSL_CTX build on the JS thread inside fetch_impl's tls-option handling (src/runtime/webcore/fetch.rs), gated on config.requires_custom_request_ctx. On failure it throws via the existing create_bun_socket_error_to_js helper (same one node:tls and bun:sql use), whose .none arm pulls the reason off the thread-local BoringSSL error queue. Four new tests in test/js/web/fetch/fetch.tls.test.ts cover cert/key mismatch, wrong passphrase, non-PEM cert, and a matching control.

Security risks

The change is in client-side TLS material handling. It does not weaken any check — the same us_ssl_ctx_from_options runs, just earlier and on a different thread — and rejectUnauthorized/verification behavior is untouched. No new user input reaches BoringSSL that didn't before. The risk surface is the extra unsafe SSL_CTX_free and the assumption that create_ssl_context's Some return is a +1 ref; us_ssl_ctx_from_options does return a fresh ctx, so that holds.

Level of scrutiny

Medium-high. TLS is security-sensitive per the review guidelines, and the fix is at the call site rather than at the layer that owns the invariant (uSockets' us_ssl_ctx_build_raw not setting *err, or the HTTP thread not draining the BoringSSL queue). The PR description argues this is deliberate — the error queue is thread-local and marshaling it across the HTTP→JS boundary would need new plumbing — and it matches how SecureContext::create_private and SQL's ConnectionCtorArgs already handle the same failure. That's a reasonable trade-off, but it's the kind of "fix location" call REVIEW.md says a maintainer should agree with.

Other factors

  • Perf: the probe build parses PEM and validates the key pair on every fetch() call with custom cert material, even when the HTTP thread's per-interned-config ctx cache would have hit. For a hot loop reusing one tls config that's N extra SSL_CTX_new/use_certificate/use_PrivateKey/free cycles on the JS thread where previously there was one build total. Not a correctness issue, but worth a maintainer deciding whether that's acceptable vs. caching the probe result or fixing at the HTTP-thread layer.
  • Memory: SSLConfig implements Drop (calls deinit), so the return Err path releases it; the options struct returned by as_usockets_for_client_verification borrows raw pointers into config, which stays alive across the FFI call.
  • Tests: verified fail-before/pass-after via the robobun comment; assertions are exact on code/library/reason and include a positive control.

ca-only / ciphers-only / serverName-only tls configs now skip the probe and
keep hitting the HTTP-thread context cache as before. The probe only fires
when cert or key is present, which is the case whose failure uSockets reports
without setting *err.
Comment thread src/runtime/webcore/fetch.rs Outdated
Comment thread src/runtime/webcore/fetch.rs Outdated
@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:51 PM PT - Jul 27th, 2026

❌ @robobun, your commit 2651349 has 1 failures in Build #83648 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36152

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

bun-36152 --bun

Comment thread src/runtime/webcore/fetch.rs 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.

I didn't find bugs, but this needs a maintainer's call: it's one of two competing fixes for the same issue (#36149 propagates the error from the HTTP thread; this PR probes on the JS thread and discards the ctx). The author explicitly left both open for a maintainer to pick, and the trade-off — extra SSL_CTX build per fetch-with-client-cert vs. richer error shape and smaller diff — is a design decision.

Checked: the probe is gated on cert/key presence only (so {rejectUnauthorized:false}/ca-only skip it), the ? early return drops config cleanly via SSLConfig::Drop, and reject_on_exception turns the thrown value into a rejected promise. The create_bun_socket_error_to_js(.none) → ERR_get_error() pattern matches SecureContext/Listener. Test fixtures (agent1-cert.pem, rsa_private_encrypted.pem, etc.) all exist.

Extended reasoning...

Overview

Adds validate_client_tls_identity() in src/runtime/webcore/fetch.rs that eagerly builds and frees an SSL_CTX on the JS thread when a fetch tls config carries client cert/key material, so a bad cert/key rejects with the BoringSSL ERR_OSSL_* code/library/reason instead of the HTTP thread's generic FailedToOpenSocket. Four new tests in fetch.tls.test.ts cover mismatch, wrong passphrase, non-PEM, and a control.

Security risks

Touches client-side TLS setup in fetch(). The change only adds a validation probe before the existing path; it doesn't alter verification, rejectUnauthorized, or how the real SSL_CTX is built on the HTTP thread. No new attack surface identified.

Level of scrutiny

Medium-high. It's TLS-adjacent runtime code, and — more importantly — the author explicitly framed this as one of two competing implementations and left the choice to a maintainer. Per the repo's review guidance ("fix bugs at the layer that owns the violated invariant"), #36149's HTTP-thread capture is arguably the more architecturally correct layer, while this PR is smaller and reuses the existing create_bun_socket_error_to_js decoration. That's a human call.

Other factors

  • The gate was narrowed (commit bd2c6c0) from requires_custom_request_ctx to cert/key-only, so ca/ciphers-only configs no longer get the probe — the trade-off table in the PR thread predates that narrowing.
  • Runtime cost: one extra SSL_CTX build+free on the JS thread per fetch with client identity. Acceptable for the use case but worth a maintainer's ack.
  • Error propagation checked: validate_client_tls_identity(...)? throws → fetch_impl returns Err(Thrown) → reject_on_exception converts to a rejected promise (fetch never throws sync). config: SSLConfig is dropped on that path and its Drop frees the C strings.
  • Tests use existing Node fixture keys, spin up a local Bun.serve({port:0}), and assert exact {code, library, reason} — strong assertions with a passing control case.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-27, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

2 participants