Skip to content

fetch(tls): surface ERR_OSSL_* for bad client cert/key instead of FailedToOpenSocket - #36149

Closed
robobun wants to merge 7 commits into
mainfrom
farm/ab4ed78b/fetch-tls-cert-error
Closed

robobun wants to merge 7 commits into
mainfrom
farm/ab4ed78b/fetch-tls-cert-error

Conversation

@robobun

@robobun robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator

fetch(url, {tls: {cert, key, passphrase, ...}}) collapsed every client certificate/key load failure into the same FailedToOpenSocket "Was there a typo in the url or port?" error, with no OpenSSL code, while node:https in the same process reports the real codes (ERR_OSSL_X509_KEY_VALUES_MISMATCH, ERR_OSSL_BAD_DECRYPT, ERR_OSSL_PEM_NO_START_LINE). This makes mTLS / enterprise-proxy client-cert misconfiguration look like a URL typo.

Reproduction

import { tls as A, expiredTls as B } from "./test/harness.ts";
using server = Bun.serve({ port: 0, tls: A, fetch: () => new Response("ok") });
for (const [name, t] of Object.entries({
  "cert/key mismatch": { cert: A.cert, key: B.key },
  "non-PEM cert":      { cert: "not a pem", key: A.key },
})) {
  try { await fetch(server.url, { tls: { ca: A.cert, ...t } }); }
  catch (e) { console.log(name.padEnd(20), e.code, "|", e.message); }
}

Before:

cert/key mismatch    FailedToOpenSocket | Was there a typo in the url or port?
non-PEM cert         FailedToOpenSocket | Was there a typo in the url or port?

After:

cert/key mismatch    ERR_OSSL_X509_KEY_VALUES_MISMATCH | error:0b000074:X.509 certificate routines:OPENSSL_internal:KEY_VALUES_MISMATCH
non-PEM cert         ERR_OSSL_PEM_NO_START_LINE | error:0900006e:PEM routines:OPENSSL_internal:NO_START_LINE

Cause

us_ssl_ctx_build_raw (packages/bun-usockets/src/crypto/openssl.c) returns NULL with *err left at CREATE_BUN_SOCKET_ERROR_NONE for client cert/key/passphrase failures; the real cause is only on the calling thread's BoringSSL error queue. SecureContext (the node:https path) reads that queue via ERR_get_error() on the JS thread and formats it with boringssl_jsc::err_to_js. The fetch-client SSL_CTX is built on the HTTP thread at two sites that never read the queue:

  • HTTPContext::init_with_opts (direct TLS connect) mapped (None, err == none) to InitError::FailedToOpenSocket, which became http::Error::FailedToOpenSocket and the "typo in the url" message in FetchTasklet::on_reject.
  • ProxyTunnel::start (http-proxy CONNECT tunnel to an https target) mapped SSLWrapper::init_from_options failure to crate::Error::ConnectionRefused.

Fix

Add http::error::take_boringssl_error() which pops and drains the thread-local BoringSSL error queue, and call it at both create_ssl_context failure sites immediately after the failed SSL_CTX_* calls. Carry the packed u32 in new InitError::ClientTLSSetup / http::Error::ClientTLSSetup variants. FetchTasklet::on_reject formats it via a new JSC-free boringssl_jsc::err_code_and_message helper (the same ERR_OSSL_<LIB>_<REASON> composition err_to_js now also uses) into a SystemError with .code (ERR_OSSL_*), .message (ERR_error_string_n output) and .path (the request URL). The explicit CA/CRL enum arms are unchanged.

Verification

test/js/web/fetch/fetch.tls.test.ts gains two it.each blocks (direct + via http CONNECT proxy) covering cert/key mismatch, encrypted key with wrong/missing passphrase, and non-PEM cert/key input; each asserts err.code matches ^ERR_OSSL_ and the old FailedToOpenSocket/ConnectionRefused/"typo in the url" shapes are gone. All ten cases fail on main and pass with this change.


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

fails on main (without fix)
ASAN without fix: 10 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 (96c737581)

test/js/web/fetch/fetch.tls.test.ts:
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity that throws should reject [1149.78ms]
(pass) fetch-tls > fetch with valid tls should not throw [1785.73ms]
(pass) fetch-tls > can handle multiple requests with non native checkServerIdentity [1845.66ms]
(pass) fetch-tls > fetch with rejectUnauthorized: false should not call checkServerIdentity [295.70ms]
(pass) fetch-tls > re-derives the Host header and TLS verification hostname from the redirect target on a cross-origin redirect [2180.69ms]
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity should work [2133.61ms]
(pass) fetch-tls > fetch with self-sign tls should throw [168.82ms]
(pass) fetch-tls > fetch with invalid tls should throw [159.18ms]
(pass) fetch-tls > fetch with checkServerIdentity failing should throw [276.95ms]
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers [665.66m
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (ed7988cfa)

test/js/web/fetch/fetch.tls.test.ts:
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers (with AbortSignal) [39.37ms]
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers [47.09ms]
(pass) fetch-tls > fetch should respect rejectUnauthorized env [38.96ms]
(pass) fetch-tls > checkServerIdentity approval still transmits the request and round-trips the response [43.85ms]
(pass) fetch-tls > re-derives the Host header and TLS verification hostname from the redirect target on a cross-origin redirect [1570.57ms]
(pass) fetch tls: client cert/key load errors surface the OpenSSL code > cert/key mismatch [1.79ms]
(pass) fetch tls: client cert/key load errors surface the OpenSSL code > encrypted key, wrong passphrase [1.68ms]
(pass) fetch tls: client cert/key load errors surface the OpenSSL code > encrypted key, no passphrase [1.58ms]
(pass) fetch tls: client cert/key load errors surface the OpenSSL code > non-PEM cert string [1.46ms]
(pass) fetch tls: client cert/key load errors surface the OpenSSL code > non-PEM key string [1.42ms]
(pas
... (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 (96c737581)

test/js/web/fetch/fetch.tls.test.ts:
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity that throws should reject [1155.69ms]
(pass) fetch-tls > fetch with valid tls should not throw [1797.39ms]
(pass) fetch-tls > can handle multiple requests with non native checkServerIdentity [1861.62ms]
(pass) fetch-tls > fetch with rejectUnauthorized: false should not call checkServerIdentity [309.79ms]
(pass) fetch-tls > re-derives the Host header and TLS verification hostname from the redirect target on a cross-origin redirect [2208.29ms]
(pass) fetch-tls > fetch with valid tls and non-native checkServerIdentity should work [2146.71ms]
(pass) fetch-tls > fetch with self-sign tls should throw [162.61ms]
(pass) fetch-tls > fetch with invalid tls should throw [161.41ms]
(pass) fetch-tls > fetch with checkServerIdentity failing should throw [281.42ms]
(pass) fetch-tls > fetch with checkServerIdentity rejects when connection closes before response headers [672.85m
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 649ms (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/http/HTTPContext.rs                   |   4 +-
 src/http/HTTPThread.rs                    |   3 +-
 src/http/InitError.rs                     |   3 +
 src/http/ProxyTunnel.rs                   |   7 +-
 src/http/error.rs                         |  12 ++++
 src/install/PackageManager.rs             |   2 +-
 src/runtime/crypto/boringssl_jsc.rs       |  55 +++++++-------
 src/runtime/webcore/fetch/FetchTasklet.rs |  20 ++++++
 test/js/web/fetch/fetch.tls.test.ts       | 116 +++++++++++++++++++++++++-----
 9 files changed, 174 insertions(+), 48 deletions(-)

gate history · 3 passed · 1 rejected · iteration 2

evidence per changed file
file                                       reads  edits  tests
src/http/HTTPContext.rs                        2      4      0
src/http/HTTPThread.rs                         3      2      0
src/http/InitError.rs                          2      3      0
src/http/ProxyTunnel.rs                        3      2      0
src/http/error.rs                              4      6      0
src/install/PackageManager.rs                  1      1      0
src/runtime/crypto/boringssl_jsc.rs            4      6      0
src/runtime/webcore/fetch/FetchTasklet.rs      3      1      0
test/js/web/fetch/fetch.tls.test.ts            6     10      0

Scope

The WebSocket client builds its SSL_CTX at two sibling sites (WebSocketProxyTunnel::start and WebSocketUpgradeClient::connect in src/http_jsc/websocket_client/) with the same queue-discarding shape. Those route through bun_http_jsc with an event-based error surface (close code + error event, not a rejected promise with a SystemError), so threading ERR_OSSL_* there needs a new http_jsc::Error variant and close-code mapping; deferred to a follow-up. This PR does not change WebSocket behavior.

…ledToOpenSocket

fetch(url, {tls: {cert, key, passphrase}}) collapsed every client
certificate/key load failure (cert/key mismatch, wrong or missing
passphrase for an encrypted key, non-PEM input) into a generic
FailedToOpenSocket "Was there a typo in the url or port?" error.
node:https in the same process already reports the real BoringSSL codes
(ERR_OSSL_X509_KEY_VALUES_MISMATCH, ERR_OSSL_BAD_DECRYPT,
ERR_OSSL_PEM_NO_START_LINE) via SecureContext.

us_ssl_ctx_build_raw returns NULL with *err left at
CREATE_BUN_SOCKET_ERROR_NONE for cert/key/passphrase failures; the real
cause is only on the calling thread's BoringSSL error queue. The
fetch-client SSL_CTX is built on the HTTP thread, and
HTTPContext::init_with_opts mapped (None, err == none) to
InitError::FailedToOpenSocket without reading the queue.

Capture ERR_get_error() in init_with_opts (same thread as the failed
SSL_CTX_* calls), carry the packed u32 across in a new
InitError::ClientTLSSetup / http::Error::ClientTLSSetup variant, and
format it on the JS thread in FetchTasklet::on_reject using the same
ERR_OSSL_<LIB>_<REASON> composition SecureContext uses. The resulting
error carries .code (ERR_OSSL_*), .message (ERR_error_string_n output)
and .path (the request URL).
@coderabbitai

coderabbitai Bot commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Client TLS error propagation

Layer / File(s) Summary
TLS error contract and extraction
src/http/InitError.rs, src/http/error.rs, src/http/HTTPContext.rs
Adds ClientTLSSetup variants, extracts packed BoringSSL errors, and preserves them during TLS initialization failures.
HTTP and proxy error propagation
src/http/HTTPThread.rs, src/http/ProxyTunnel.rs, src/install/PackageManager.rs
Propagates client TLS setup failures through HTTP connections, proxy failures, and startup error handling.
JavaScript TLS error conversion
src/runtime/crypto/boringssl_jsc.rs, src/runtime/webcore/fetch/FetchTasklet.rs
Builds ERR_OSSL_* codes and messages and exposes them in fetch system errors with request paths.
Direct and proxied TLS tests
test/js/web/fetch/fetch.tls.test.ts
Tests invalid client TLS materials for direct requests and HTTP CONNECT proxy requests.

Possibly related PRs

  • oven-sh/bun#35988: Both changes modify fetch error construction in FetchTasklet::on_reject.
  • oven-sh/bun#35998: Both changes alter structured fetch network error handling and code mapping.

Suggested reviewers: jarred-sumner

🚥 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 The title precisely summarizes the main change: surfacing BoringSSL client TLS errors instead of FailedToOpenSocket.
Description check ✅ Passed The PR explains the bug, root cause, fix, and verification, covering the required intent and testing details.

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

Comment thread src/http/HTTPThread.rs
Comment thread src/runtime/crypto/boringssl_jsc.rs
ProxyTunnel::start builds the inner-TLS SSL_CTX for the
http-proxy -> https-target CONNECT tunnel via
SSLWrapper::init_from_options; on bad cert/key that site returned
InitError::InvalidOptions and was mapped to ConnectionRefused without
reading the BoringSSL queue. Capture ERR_get_error() there too (same
thread as the failed SSL_CTX_* calls) and surface
Error::ClientTLSSetup(packed) so the proxy path reports the same
ERR_OSSL_* code as the direct path.

Also fold the ERR_OSSL_<LIB>_<REASON> code composition into a shared
build_err_ossl_code helper and have err_to_js call err_code_and_message
for its (code, message), so the fetch and node:https paths cannot drift.
Comment thread src/http/HTTPContext.rs Outdated
Comment thread src/http/InitError.rs Outdated
Comment thread src/http/ProxyTunnel.rs Outdated
Comment thread src/http/error.rs Outdated
Comment thread src/runtime/crypto/boringssl_jsc.rs Outdated
Comment thread src/runtime/crypto/boringssl_jsc.rs Outdated
Comment thread src/runtime/crypto/boringssl_jsc.rs Outdated
The ERR_get_error/ERR_clear_error pattern now has a single named helper
in http::error that both HTTPContext and ProxyTunnel call, so the
thread-local-queue invariant is documented once and the call sites are
self-explanatory.
@robobun

robobun commented Jul 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:09 PM PT - Jul 27th, 2026

❌ @robobun, your commit 96c7375 has 1 failures in Build #83664 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36149

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

bun-36149 --bun

Comment thread src/http/InitError.rs Outdated
Comment thread src/http/error.rs Outdated
Comment thread src/http/error.rs Outdated
Comment thread src/runtime/crypto/boringssl_jsc.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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/http/ProxyTunnel.rs:635-641 — The follow-up commit adds the same ERR_get_error() → if packed != 0 { ERR_clear_error(); ClientTLSSetup(packed) } else { <fallback> } block at both HTTPContext::init_with_opts and here in ProxyTunnel::start, each preceded by a near-identical 4-line explanatory comment (both already flagged by comment-cop). A small fn take_boringssl_error() -> Option<u32> in the http crate would let each site collapse to take_boringssl_error().map(…::ClientTLSSetup).unwrap_or(<fallback>), centralise the queue-read discipline in one place, and drop the duplicated comment — the same within-diff dedup this PR already applied to boringssl_jsc.rs (build_err_ossl_code). Small enough to ship as-is; noting for consistency.

    Extended reasoning...

    What the issue is

    The follow-up commit (ae8459c5, addressing the earlier proxy-tunnel review comment) introduces the same multi-line block at two sites in the same diff:

    src/http/HTTPContext.rs:503-509 (inside init_with_opts):

    let packed = bun_boringssl_sys::ERR_get_error();
    if packed != 0 {
        bun_boringssl_sys::ERR_clear_error();
        InitError::ClientTLSSetup(packed)
    } else {
        InitError::FailedToOpenSocket
    }

    src/http/ProxyTunnel.rs:635-641 (inside ProxyTunnel::start):

    let packed = bun_boringssl_sys::ERR_get_error();
    let fail = if packed != 0 {
        bun_boringssl_sys::ERR_clear_error();
        crate::Error::ClientTLSSetup(packed)
    } else {
        crate::Error::ConnectionRefused
    };

    Each is preceded by a near-identical 4-line comment explaining the same BoringSSL-error-queue capture rationale ("create_ssl_context leaves the detail on this thread's BoringSSL error queue … same thread as the failed SSL_CTX_* calls"). The comment-cop bot has already flagged both comments independently.

    Why this is flagged

    REVIEW.md, Simplest honest shape; deduplicate within your own diff: "The second time a multi-line block appears in your diff, extract a named helper and use it at EVERY parallel site." This PR already applied that exact rule once in this review cycle — the previous round extracted build_err_ossl_code in boringssl_jsc.rs so err_code_and_message and err_to_js share one code-composition path. Applying it consistently here means the two create_ssl_context-failure sites should share the queue-read step the same way.

    Addressing the counterargument

    It's true that (a) the codebase already open-codes ERR_get_error() + ERR_clear_error() inline at ~10 pre-existing sites (CryptoHasher.rs, tls_socket_functions.rs, Listener.rs, etc.) with no shared helper, (b) the two blocks return different enum types with different fallbacks, and (c) REVIEW.md also says "don't ride file-wide standardization on a focused bugfix." These are fair points and are why this is a nit, not a blocker.

    But the within-diff rule is scoped to your own diff, not the pre-existing sites: extracting a small helper for the two new sites does not require touching the other ten (that would indeed be scope creep). The different enum types are exactly why the natural factoring is fn take_boringssl_error() -> Option<u32> — it captures only the shared queue-read discipline and lets each caller map Some(p) / None to its own variant. And the fact that both sites need the same 4-line comment to justify the same 3 lines is itself the signal: a named helper's doc-comment carries the rationale once, and comment-cop stops firing at both sites.

    Step-by-step drift example

    1. A future change decides the last queued error is the interesting one for client-cert failures (BoringSSL pushes several entries for a PEM parse failure; the deepest is often the most specific), and switches HTTPContext.rs to ERR_peek_last_error() followed by ERR_clear_error().
    2. ProxyTunnel.rs is not updated — nothing links the two sites except the duplicated comment.
    3. Now fetch(url, {tls: {cert: "not a pem"}}) reports one ERR_OSSL_* code, while fetch(url, {proxy: 'http://…', tls: {cert: "not a pem"}}) reports a different one for the identical input — precisely the direct-vs-proxy divergence this PR is closing.
    4. The test suite asserts ^ERR_OSSL_ at both sites, so both still pass; the drift is silent.

    How to fix

    Add to the http crate (or wherever both sites can reach it):

    /// Pop the top of this thread's BoringSSL error queue after a failed
    /// `create_ssl_context`. `us_ssl_ctx_build_raw` returns NULL with the enum
    /// left at `.none` for client cert/key/passphrase failures; the real cause
    /// is only here. Clears the rest of the queue on hit.
    fn take_boringssl_error() -> Option<u32> {
        let packed = bun_boringssl_sys::ERR_get_error();
        if packed != 0 { bun_boringssl_sys::ERR_clear_error(); Some(packed) } else { None }
    }

    Then HTTPContext.rs becomes take_boringssl_error().map(InitError::ClientTLSSetup).unwrap_or(InitError::FailedToOpenSocket) and ProxyTunnel.rs becomes take_boringssl_error().map(crate::Error::ClientTLSSetup).unwrap_or(crate::Error::ConnectionRefused). The 4-line comment at each call site collapses into the helper's doc-comment.

    Why nit

    Nothing breaks if this ships as-is: both sites are correct today, the shared core is only ~3 substantive lines, and the open-coded pattern matches existing convention elsewhere in the tree. This is a maintainability/consistency observation — the same rule was applied to boringssl_jsc.rs in this PR, and the duplicated comment-cop hits suggest the code wants the same treatment here — not a functional defect.

Comment thread test/js/web/fetch/fetch.tls.test.ts
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

The take_boringssl_error() helper was extracted in 142c645 (before this review landed); both sites are now take_boringssl_error().map(|p| …::ClientTLSSetup(p.get())).unwrap_or(<fallback>). The proxy test's net.Socket error listener was added in e09a137.

@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 three rounds of prior feedback are addressed (proxy-tunnel path now covered, err_to_js deduped through the new helper, proxy test swallows ECONNRESET). No further issues found, but this threads a new error variant across the HTTP-thread boundary and refactors the shared err_to_js used by node:https/SecureContext, so it's worth a maintainer look.

What was reviewed:

  • take_boringssl_error() runs on the HTTP thread immediately after each failed create_ssl_context (both sites), reading the correct thread-local queue; the packed u32 crosses to the JS thread where err_code_and_message does only pure table lookups.
  • err_to_js refactor preserves behavior: .code still gated on non-empty (⇔ reason present), .reason/.library/.function unchanged.
  • All InitError/http::Error match sites updated exhaustively (HTTPThread, PackageManager, on_init_error_noop).
  • Test fixtures rsa_cert.crt/rsa_private_encrypted.pem exist; 10 cases cover direct + CONNECT-proxy paths.
Extended reasoning...

Overview

The PR adds a new ClientTLSSetup(u32) variant to both http::InitError and http::Error, carrying a packed BoringSSL error code. A new http::error::take_boringssl_error() helper pops and drains the thread-local BoringSSL error queue at the two create_ssl_context failure sites (HTTPContext::init_with_opts for direct TLS, ProxyTunnel::start for the CONNECT-tunnel inner TLS). FetchTasklet::on_reject formats the packed code via a new JSC-free boringssl_jsc::err_code_and_message helper into a SystemError with ERR_OSSL_* .code, the ERR_error_string_n .message, and the request URL as .path. err_to_js (used by SecureContext / node:https) is refactored to call the same helper so the two paths cannot drift. Match arms in HTTPThread.rs, PackageManager.rs, and on_init_error_noop are updated for exhaustiveness. Ten new test cases (5 direct, 5 via http CONNECT proxy) assert err.code matches ^ERR_OSSL_ and the old generic codes/messages are gone.

Security risks

None identified. This is diagnostic-only: it surfaces more information about why a client-side TLS setup failed, which was previously discarded. No new inputs are parsed, no validation is loosened, and the failure paths still fail the request. The BoringSSL error strings come from static tables, not user input.

Level of scrutiny

Medium-high. The change is well-scoped and additive (new enum variant, new fallback branch at each failure site, existing arms untouched), but it threads state across the HTTP-thread → JS-thread boundary and refactors err_to_js, a shared function on the node:https / SecureContext error path. The thread-affinity concern (BoringSSL's error queue is thread-local) is handled correctly: take_boringssl_error() runs on the HTTP thread immediately after the failing FFI call, and only the packed u32 crosses threads; err_code_and_message on the JS side calls only ERR_error_string_n / ERR_reason_error_string, which are pure lookups on the packed code, not queue reads.

Other factors

This PR has been through three prior review rounds from me, each addressed by a follow-up commit (ae8459c covered the ProxyTunnel sibling site and deduped err_to_js; 142c645 extracted the shared take_boringssl_error helper; 9314515 trimmed comments; e09a137 added the ECONNRESET swallow on the test proxy socket). The comment-cop bot's long-comment complaints are all resolved. The err_to_js refactor preserves behavior: the .code property is still only attached when the reason string is present (empty code Vec ⇔ ERR_reason_error_string returned null), and .library/.function/.reason are still looked up independently. The double ERR_reason_error_string call is a pure table lookup with no side effects. Given nine files touched across the HTTP client error plumbing plus a refactor of a shared crypto formatter, deferring to a maintainer for final sign-off.

The fixture spawns a debug+ASAN bun subprocess that drives hundreds of
TLS handshakes; on main with no other changes it already ran at
~4.2-5.5s against the 5s default timeout and flaked ~1 in 5 runs.
Give it an explicit isASAN-gated timeout.

@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 is addressed and the bug hunter found nothing new this round. Deferring to a human for final sign-off since this threads a new error variant through the HTTP-thread TLS setup path (direct + proxy-tunnel) and refactors err_to_js (which node:https/node:crypto also route through).

Checked: take_boringssl_error() runs on the HTTP thread immediately after create_ssl_context fails at both sites (nothing in SSLWrapper::init_from_options touches the queue between the failed build and the Err return); err_code_and_message is thread-agnostic (static-table lookups only); the err_to_js refactor preserves the .reason/.code coupling (code.is_empty() ⇔ reason lookup returned None); the referenced rsa_cert.crt/rsa_private_encrypted.pem fixtures exist.

Extended reasoning...

Overview

Adds InitError::ClientTLSSetup(u32) / http::Error::ClientTLSSetup(u32) carrying a packed BoringSSL error, populated via a new take_boringssl_error() helper at the two create_ssl_context failure sites (HTTPContext::init_with_opts and ProxyTunnel::start). FetchTasklet::on_reject formats it via a new JSC-free err_code_and_message() helper, and err_to_js is refactored to share that helper. Exhaustive-match arms added in HTTPThread.rs and PackageManager.rs. Ten new test cases (5 inputs × direct/proxy paths); an unrelated fixture gets an ASAN timeout bump.

Security risks

None identified. This only changes which error message/code surfaces when client-cert SSL_CTX construction has already failed — no validation logic, no success path, no credential handling is touched. take_boringssl_error() drains the thread-local queue after reading, so stale errors don't leak into later operations.

Level of scrutiny

Medium-high. The change is scoped (error propagation + message formatting) and has good test coverage of both entry points, but it spans the HTTP-thread/JS-thread boundary, adds a variant to two widely-matched error enums, and refactors err_to_js which is also on the node:crypto/node:https path. It went through three review rounds (proxy-tunnel sibling site, err_to_js dedup, comment-cop trims, missing socket error listener) — all addressed. That iteration count and the TLS-adjacent surface area put it just past my auto-approve bar.

Other factors

  • InitError/http::Error remain Copy/Eq with the new u32 payload.
  • Verified SSLWrapper::init_from_options (src/uws/lib.rs:502-512) returns immediately after create_ssl_context fails without disturbing the BoringSSL queue, so the ProxyTunnel-side take_boringssl_error() reads the right error.
  • err_code_and_message uses only ERR_error_string_n/ERR_reason_error_string (static-table lookups, not queue reads), so calling it on the JS thread with a u32 captured on the HTTP thread is sound.
  • The err_to_js refactor is behavior-preserving: .code was previously set iff ERR_reason_error_string returned non-null; now it's set iff !code.is_empty(), and code is built exactly when that same lookup succeeds.
  • The drive-by isASAN ? 20000 : 10000 timeout on the cert-mismatch-churn fixture is justified inline (already at 4.2-5.0s vs 5s default on main).

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

Beyond the inline nit: verified the err_to_js refactor is behavior-preserving (.code set iff ERR_reason_error_string non-null, same predicate as before); take_boringssl_error() runs on the HTTP thread immediately after each failed create_ssl_context so concurrent fetches (serialized there) can't cross-contaminate the thread-local queue; and err_code_and_message on the JS thread only does static-table lookups on the packed u32. All three prior findings (ProxyTunnel sibling site, err_to_js dedup, proxy-test ECONNRESET listener) are addressed in the current diff.

Extended reasoning...

All three findings from earlier passes are resolved in the current diff: ProxyTunnel::start now calls take_boringssl_error() and maps to ClientTLSSetup (with a covering via http CONNECT proxy test block), err_to_js now delegates to err_code_and_message so the two ERR_OSSL_* composition paths share one implementation, and the proxy test's net.Socket has client.on('error', () => {}). This run additionally checked that the err_to_js refactor did not change when .code/.reason are attached, and that the HTTP-thread-local BoringSSL error queue read is race-free under describe.concurrent (each create_ssl_context → ERR_get_error → ERR_clear_error sequence completes before the next queued request runs). Deferring rather than approving because the change spans 9 files including a refactor of err_to_js (used by node:crypto/https) and HTTP-thread error propagation — worth a human glance even with the nit being the only open item.

Comment thread src/http/ProxyTunnel.rs
@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the diff itself is green. fetch.tls.test.ts (including the 10 new cases) passes on every lane in both #83659 and the re-roll #83664.

Remaining red is unrelated to this change:

Ready for review.

@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