Skip to content

node:tls: implement setDefaultCACertificates()#30570

Open
robobun wants to merge 2 commits into
mainfrom
farm/06cd3e87/tls-set-default-ca-certificates
Open

node:tls: implement setDefaultCACertificates()#30570
robobun wants to merge 2 commits into
mainfrom
farm/06cd3e87/tls-set-default-ca-certificates

Conversation

@robobun

@robobun robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Implements tls.setDefaultCACertificates(certs) (added in Node.js v24.5.0).

Fixes #24340
Fixes #13868

Repro

import tls from 'node:tls';
tls.setDefaultCACertificates(tls.getCACertificates('system'));

Before: TypeError: tls.setDefaultCACertificates is not a function
After: replaces the default trust store with the system CA set.

How

  • packages/bun-usockets/src/crypto/root_certs.cpp — add a mutex-guarded user-override STACK_OF(X509)*. When present, us_get_default_ca_store() builds the store exclusively from it (Node.js does not merge bundled/system/extra back in). The cached shared store moves from std::call_once to a mutex so setDefaultCACertificates can invalidate it for subsequent connections; in-flight SSL*s keep their own X509_STORE reference via SSL_set0_verify_cert_store.
  • packages/bun-usockets/src/crypto/openssl.chttps.request() / fetch() build their HTTPS SSL_CTX once on the HTTP thread with request_cert=1 and no ca, baking a snapshot of the default store into the CTX. Tag such CTXs with a new ex_data flag (us_ctx_default_ca_ex_idx) so us_internal_ssl_attach() can override the verify store per-SSL with the current shared default whenever setDefaultCACertificates has been called. CTXs built with an explicit ca are left untouched.
  • src/jsc/bindings/NodeTLS.cppresetRootCertStore accepts an array of PEM strings / ArrayBufferViews (each entry may contain multiple certs), deduplicates by X509_cmp identity, and installs the result. getUserRootCertificates returns undefined when no override is installed (so the JS side falls back to bundled/system/extra) or a frozen PEM array otherwise; snapshot is taken under the root-cert mutex so a concurrent Worker swapping certs can't free them mid-serialise.
  • src/js/node/tls.tssetDefaultCACertificates(certs) validates certs is an Array of string | ArrayBufferView (ERR_INVALID_ARG_TYPE otherwise), calls into native, and invalidates the JS-side cache. getCACertificates('default') queries native for the override on every call so a Worker sees an override installed on another thread.

Node.js parity notes

  • Deduplicates supplied certificates (same cert passed three times → one entry).
  • Empty array is a valid override that makes new default-CA connections fail verification.
  • Invalid PEM throws ERR_CRYPTO_OPERATION_FAILED and leaves the current defaults untouched.
  • getCACertificates('bundled') / 'system' / 'extra' remain unchanged.
  • Node.js scopes the override per-thread (thread_local); this implementation is process-global, guarded by a mutex so Workers don't race each other. getCACertificates('default') reflects the process-global override from any Worker.

Tests

test/js/node/tls/node-tls-set-default-ca-certificates.test.ts covers: existence, input validation (non-array / bad element types), round-tripping through getCACertificates('default'), deduplication, Buffer/Uint8Array/DataView inputs, invalid-PEM rollback, Worker visibility, and an end-to-end HTTPS sequence of fail (no CA) → install CA → succeed → clear CA → fail that proves the cached HTTP-thread SSL_CTX picks up runtime changes.

Upstream Node.js tests added (unmodified):

  • test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js
  • test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js

Also added assertEqualCerts / includesCert helpers to test/js/node/test/common/tls.js to match upstream.


This also provides the "runtime hook to add a CA cert" that #13868 asks for — in a standalone executable you can embed a PEM string and call tls.setDefaultCACertificates([...tls.getCACertificates('bundled'), myCA]) at startup.


Rebase note (squashed onto current main): main's hardening work (#31339) changed us_ssl_ctx_build_raw so explicit ca/ca_file_name options now populate the SSL_CTX's own private store instead of calling us_get_default_ca_store(). This composes cleanly with this PR — the default-store override and the ex_data refresh marker only apply to the request_cert-without-ca path, which still uses the process default store. The only manual conflict was the comment block above us_get_shared_default_ca_store() in root_certs.cpp.

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:23 PM PT - May 25th, 2026

@robobun, your commit b54356c has 2 failures in Build #58077 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 30570

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

bun-30570 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Ability to set NODE_EXTRA_CA_CERTS in standalone executable #13868 - Requests a runtime hook to set custom CA certificates in standalone executables; tls.setDefaultCACertificates() provides exactly that API

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

Fixes #13868

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented May 12, 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

Adds process-global, mutex-protected user override for the default TLS CA store with native setter/getter, JSC host functions for reset/read (PEM parsing and dedupe), TypeScript binding tls.setDefaultCACertificates(), and comprehensive tests.

Changes

User-Overridable Default CA Certificates

Layer / File(s) Summary
Native C++ Storage and APIs (uSockets)
packages/bun-usockets/src/crypto/root_certs.cpp, packages/bun-usockets/src/crypto/root_certs_header.h, packages/bun-usockets/src/crypto/openssl.c
Process-global mutex-protected storage for user-overridden CA certificates, exported us_set_user_root_certs()/inspection APIs, a locked default-store builder that respects overrides, an invalidatable cached shared X509_STORE, and SSL_CTX marking + per-SSL verify-store override when applicable.
JSC Host Functions and PEM Parsing
src/jsc/bindings/NodeTLS.cpp, src/jsc/bindings/NodeTLS.h
Adds resetRootCertStore and getUserRootCertificates host functions: accept JS strings or ArrayBufferView, parse PEM bundles with OpenSSL error handling, deduplicate certificates by DER/X509 identity, commit or clear the native override, and return frozen arrays of PEM strings for snapshots.
TypeScript Module API and Validation
src/js/node/tls.ts
Exports setDefaultCACertificates(certs) on node:tls, validates the argument as an array of strings/ArrayBufferView, calls the native reset binding, clears VM-local cached default certificates, and consults the native getUserRootCertificates when caching defaults.
Certificate Comparison Test Utilities
test/js/node/test/common/tls.js
Adds extractMetadata, assertEqualCerts, and includesCert helpers that use crypto.X509Certificate to compare certificates by serialNumber, issuer, and subject.
Comprehensive Test Suite
test/js/node/tls/node-tls-set-default-ca-certificates.test.ts, test/js/node/test/parallel/test-tls-set-default-ca-certificates-*.js
New tests (spawned in subprocesses) that validate API presence, input/type errors (index-specific messages), empty-array clearing, deduplication, supported input types (Buffer, Uint8Array, DataView), invalid PEM handling without mutating prior state, worker visibility, and that overriding the default trust set affects TLS/HTTPS verification.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely summarizes the main change: implementing the setDefaultCACertificates() API for the node:tls module.
Linked Issues check ✅ Passed The PR successfully addresses both #24340 (implementing setDefaultCACertificates API) and #13868 (runtime hook for custom CAs in standalone executables) through native bindings, proper input validation, deduplication, error handling, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing setDefaultCACertificates and supporting infrastructure: native C++ modifications for cert management, JSC bindings, TypeScript wrapper, test utilities, and integration tests—all aligned with the stated objectives.
Description check ✅ Passed The PR description comprehensively covers the implementation of tls.setDefaultCACertificates(), including what the PR does, how it works across multiple files, parity notes, test coverage, and a rebase note addressing merge conflicts.

✏️ 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.

@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: 3

🤖 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 `@packages/bun-usockets/src/crypto/root_certs.cpp`:
- Around line 193-205: The override state is currently process-global
(shared_store_mutex, shared_store, user_root_certs, has_user_root_certs) which
allows one Worker to affect all Workers; change these globals to be thread-local
so the override is scoped per Worker/thread: replace the static globals with
thread_local equivalents (e.g., thread_local X509_STORE *shared_store,
thread_local STACK_OF(X509)* user_root_certs, thread_local bool
has_user_root_certs) and remove or narrow the use of shared_store_mutex
accordingly so that us_get_default_ca_store() and
us_get_shared_default_ca_store() operate on per-thread stores and no longer
silently change TLS verification for other Workers. Ensure any
initialization/cleanup paths that reference those symbols use the thread-local
variants.

In `@src/jsc/bindings/NodeTLS.cpp`:
- Around line 277-300: The loop currently walking STACK_OF(X509)* certs returned
by us_get_user_root_certs() can race with us_set_user_root_certs(); take a
stable snapshot under the same lock used by us_set_user_root_certs() (or perform
PEM serialization while holding that lock) before iterating: acquire the lock,
copy or up-ref each X509 (or serialize to PEM while locked) so
sk_X509_num/sk_X509_value are safe, then release the lock and continue the
BIO/PEM conversion and the existing error handling (references:
us_get_user_root_certs, us_set_user_root_certs, certs, sk_X509_num,
sk_X509_value, BIO_new/BIO_free, PEM_write_bio_X509, throwOutOfMemoryError,
throwError).

In `@test/js/node/tls/node-tls-set-default-ca-certificates.test.ts`:
- Around line 174-179: The test currently only checks lengths after rollback
which can false-pass; change the assertions to verify the actual certificates
were restored by comparing the certificate arrays directly (use deep equality
between the variables before and after returned from
tls.getCACertificates("default")) or, alternatively, compare extracted cert
metadata (e.g., fingerprint/subject fields) from before and after to ensure the
rollback restored the exact same cert set; update the assertions around
tls.getCACertificates("default") and tls.setDefaultCACertificates(["not a
certificate"]) accordingly (replace assert.strictEqual(after.length,
before.length) with a deep comparison of before and after or their metadata).
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 528db0aa-00c9-43cc-b433-8367014e6e01

📥 Commits

Reviewing files that changed from the base of the PR and between 314ffe3 and 3a8dc34.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/crypto/root_certs.cpp
  • packages/bun-usockets/src/crypto/root_certs_header.h
  • src/js/node/tls.ts
  • src/jsc/bindings/NodeTLS.cpp
  • src/jsc/bindings/NodeTLS.h
  • test/js/node/test/common/tls.js
  • test/js/node/tls/node-tls-set-default-ca-certificates.test.ts

Comment thread packages/bun-usockets/src/crypto/root_certs.cpp Outdated
Comment thread src/jsc/bindings/NodeTLS.cpp Outdated
Comment thread test/js/node/tls/node-tls-set-default-ca-certificates.test.ts 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: 2

♻️ Duplicate comments (1)
src/jsc/bindings/NodeTLS.cpp (1)

277-300: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Take a stable snapshot before walking user_root_certs.

This still iterates the raw STACK_OF(X509)* returned by us_get_user_root_certs() with no synchronization. A concurrent tls.setDefaultCACertificates() can free or replace that stack mid-loop and turn sk_X509_value / PEM_write_bio_X509 into a use-after-free.

🤖 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 `@src/jsc/bindings/NodeTLS.cpp` around lines 277 - 300, The code currently
iterates the raw STACK_OF(X509)* from us_get_user_root_certs() (using
sk_X509_num/sk_X509_value and calling PEM_write_bio_X509) without
synchronization, risking use-after-free if tls.setDefaultCACertificates()
replaces or frees the stack; to fix, take a stable snapshot immediately after
calling us_get_user_root_certs(): copy the X509* entries into a local container
(or call X509_up_ref on each and push into std::vector<X509*>) and then iterate
that snapshot when calling PEM_write_bio_X509/sk_X509_value; after the loop
release the references (X509_free) or clear the vector; handle nullptr/zero-size
the same as before and preserve existing error/BIO handling (functions
referenced: us_get_user_root_certs, sk_X509_num, sk_X509_value,
PEM_write_bio_X509).
🤖 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/jsc/bindings/NodeTLS.cpp`:
- Around line 195-200: The CA override is currently process-global via the
static symbols user_root_certs and shared_store_mutex, causing one Worker to
affect all Workers; change the implementation so the override is scoped
per-Worker (as Node.js does) by moving user_root_certs and its mutex out of
static globals into per-Worker storage (e.g., thread_local or a member on the
Worker/Isolate object) and update us_set_user_root_certs, any getters/setters,
and code paths that reference shared_store_mutex to operate on the per-Worker
variable/lock instead; ensure initialization, cleanup, and access patterns
mirror Node.js semantics (explicit empty override vs no override) and that
concurrent access is protected by the per-Worker mutex.
- Around line 151-156: The function appendX509sFromPEM performs an unchecked
cast of data.size() to int for BIO_new_mem_buf; add a guard at the top of
appendX509sFromPEM that rejects inputs where data.size() > INT_MAX before
calling BIO_new_mem_buf (and before the size_t→int cast), set/put an OpenSSL
error (e.g., via ERR_put_error) and return a non-zero error code (like
ERR_peek_last_error()) so oversized buffers are rejected safely; reference
appendX509sFromPEM, data.size(), BIO_new_mem_buf, and INT_MAX when locating the
change.

---

Duplicate comments:
In `@src/jsc/bindings/NodeTLS.cpp`:
- Around line 277-300: The code currently iterates the raw STACK_OF(X509)* from
us_get_user_root_certs() (using sk_X509_num/sk_X509_value and calling
PEM_write_bio_X509) without synchronization, risking use-after-free if
tls.setDefaultCACertificates() replaces or frees the stack; to fix, take a
stable snapshot immediately after calling us_get_user_root_certs(): copy the
X509* entries into a local container (or call X509_up_ref on each and push into
std::vector<X509*>) and then iterate that snapshot when calling
PEM_write_bio_X509/sk_X509_value; after the loop release the references
(X509_free) or clear the vector; handle nullptr/zero-size the same as before and
preserve existing error/BIO handling (functions referenced:
us_get_user_root_certs, sk_X509_num, sk_X509_value, PEM_write_bio_X509).
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 69012620-6f50-4000-9ffa-31759fb3854d

📥 Commits

Reviewing files that changed from the base of the PR and between 3a8dc34 and b6447bd.

📒 Files selected for processing (1)
  • src/jsc/bindings/NodeTLS.cpp

Comment thread src/jsc/bindings/NodeTLS.cpp
Comment thread src/jsc/bindings/NodeTLS.cpp
Comment thread packages/bun-usockets/src/crypto/root_certs.cpp Outdated
Comment thread src/js/node/tls.ts 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: 2

♻️ Duplicate comments (1)
src/jsc/bindings/NodeTLS.cpp (1)

151-156: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard the size_tint cast before BIO_new_mem_buf().

BIO_new_mem_buf() takes a signed length. If data.size() > INT_MAX, the cast wraps negative and BoringSSL/OpenSSL switches to NUL-terminated semantics, so an oversized ArrayBufferView can read past the provided buffer. Reject oversized inputs before the cast and surface the existing crypto failure path.

🤖 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 `@src/jsc/bindings/NodeTLS.cpp` around lines 151 - 156, In appendX509sFromPEM,
guard the size_t→int cast before calling BIO_new_mem_buf by checking if
data.size() > INT_MAX and rejecting oversized inputs via the same crypto failure
path (i.e., return the existing error code used on failure such as
ERR_peek_last_error()) so the cast is never performed and BIO_new_mem_buf is not
given a wrapped negative length; adjust the logic around
BIO_new_mem_buf/BIO_new_mem_buf(data.data(), static_cast<int>(data.size()))
accordingly.
🤖 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/jsc/bindings/NodeTLS.cpp`:
- Around line 159-163: The loop that reads certs with PEM_read_bio_X509 and
calls sk_X509_push currently ignores sk_X509_push's return value, so on
allocation failure the code still increments pushed (misleading count) and in
the other location calls X509_up_ref before pushing (causing leaks when push
fails); update both sites to check sk_X509_push() and treat a return of 0 as an
error: if push fails, free the newly created X509 (and if X509_up_ref was
already called, decrement/refclean appropriately), pop and free any certs
already pushed and free the stack, then return/report an error instead of
continuing; reference the PEM_read_bio_X509 loop, the sk_X509_push calls,
X509_up_ref usage, and the us_set_user_root_certs call when implementing the
unwind and error handling.

In `@test/js/node/tls/node-tls-set-default-ca-certificates.test.ts`:
- Around line 219-228: The failing assertion uses a different hostname for req2
which can mask hostname-mismatch or connection errors; change the https.request
options for req2 to reuse "localhost" (or include servername: "localhost") so
the same host is used, and then only treat the test as passing if the emitted
error is a TLS verification failure (e.g., check err.code/err.message for a
TLS/certificate verification indicator) rather than any connection error; update
the req2 creation and its error assertion around
tls.setDefaultCACertificates([]), referencing tls.setDefaultCACertificates,
https.request/req2, and the error handler to enforce verification-specific
failure.

---

Duplicate comments:
In `@src/jsc/bindings/NodeTLS.cpp`:
- Around line 151-156: In appendX509sFromPEM, guard the size_t→int cast before
calling BIO_new_mem_buf by checking if data.size() > INT_MAX and rejecting
oversized inputs via the same crypto failure path (i.e., return the existing
error code used on failure such as ERR_peek_last_error()) so the cast is never
performed and BIO_new_mem_buf is not given a wrapped negative length; adjust the
logic around BIO_new_mem_buf/BIO_new_mem_buf(data.data(),
static_cast<int>(data.size())) accordingly.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7ce79f9f-5824-4305-98c5-b16629dfa1e9

📥 Commits

Reviewing files that changed from the base of the PR and between b6447bd and ac2faee.

📒 Files selected for processing (4)
  • packages/bun-usockets/src/crypto/root_certs.cpp
  • packages/bun-usockets/src/crypto/root_certs_header.h
  • src/jsc/bindings/NodeTLS.cpp
  • test/js/node/tls/node-tls-set-default-ca-certificates.test.ts

Comment thread src/jsc/bindings/NodeTLS.cpp
Comment thread test/js/node/tls/node-tls-set-default-ca-certificates.test.ts 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

♻️ Duplicate comments (1)
src/jsc/bindings/NodeTLS.cpp (1)

162-165: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Check sk_X509_push() and unwind on failure.

Line 163 and Line 265 ignore stack insertion failure. If the stack grow fails, the parse path leaks x, and the dedupe path leaks the extra X509_up_ref() while still committing a truncated CA set. Treat a falsey sk_X509_push() as fatal and clean up before returning.

Suggested fix
 while (X509* x = PEM_read_bio_X509(bio, nullptr, [](char*, int, int, void*) -> int { return 0; }, nullptr)) {
-    sk_X509_push(out, x);
+    if (!sk_X509_push(out, x)) {
+        X509_free(x);
+        BIO_free(bio);
+        while (pushed-- > 0) {
+            X509_free(sk_X509_pop(out));
+        }
+        return ERR_peek_last_error();
+    }
     pushed++;
 }
 for (int i = 0; i < (int)sk_X509_num(parsed); i++) {
     X509* cert = sk_X509_value(parsed, i);
     if (seen.insert(cert).second) {
         X509_up_ref(cert);
-        sk_X509_push(deduped, cert);
+        if (!sk_X509_push(deduped, cert)) {
+            X509_free(cert); // balance X509_up_ref
+            sk_X509_pop_free(deduped, X509_free);
+            freeParsed();
+            throwOutOfMemoryError(globalObject, scope);
+            return {};
+        }
     }
 }

Also applies to: 261-266

🤖 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 `@src/jsc/bindings/NodeTLS.cpp` around lines 162 - 165, The loop that calls
PEM_read_bio_X509 and then sk_X509_push does not handle a falsey sk_X509_push
result, which can leak the newly read X509* (from PEM_read_bio_X509) and, in the
dedupe path, leak the extra X509_up_ref() and commit a truncated CA set; modify
the code in NodeTLS.cpp around the PEM_read_bio_X509 loop and the dedupe/commit
path to treat sk_X509_push failure as fatal: on any sk_X509_push() == 0,
immediately free the pushed X509* (and any already-pushed stack members), undo
any X509_up_ref() increments, clear/ free the target STACK_OF(X509)* (out) and
return an error/abort the parse rather than proceeding to dedupe/commit so no
leaked X509 or inconsistent CA set remains.
🤖 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/jsc/bindings/NodeTLS.cpp`:
- Around line 168-179: The code currently treats PEM_R_NO_START_LINE as EOF
whenever a certificate was parsed (pushed>0) but does not validate trailing
bytes; update the branch that checks ERR_GET_LIB(last)==ERR_LIB_PEM &&
ERR_GET_REASON(last)==PEM_R_NO_START_LINE so that before calling
ERR_clear_error() and returning success it inspects the remaining input buffer
used by this parsing routine (the buffer pointer/remaining length variables in
this function) and verifies all remaining bytes are empty or whitespace only; if
any non-whitespace data remains, do not clear the error or return success—let
the error path run (which rolls back via the pushed/sk_X509_pop/X509_free loop)
so malformed trailing data causes failure.

---

Duplicate comments:
In `@src/jsc/bindings/NodeTLS.cpp`:
- Around line 162-165: The loop that calls PEM_read_bio_X509 and then
sk_X509_push does not handle a falsey sk_X509_push result, which can leak the
newly read X509* (from PEM_read_bio_X509) and, in the dedupe path, leak the
extra X509_up_ref() and commit a truncated CA set; modify the code in
NodeTLS.cpp around the PEM_read_bio_X509 loop and the dedupe/commit path to
treat sk_X509_push failure as fatal: on any sk_X509_push() == 0, immediately
free the pushed X509* (and any already-pushed stack members), undo any
X509_up_ref() increments, clear/ free the target STACK_OF(X509)* (out) and
return an error/abort the parse rather than proceeding to dedupe/commit so no
leaked X509 or inconsistent CA set remains.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 82a9b761-353f-4264-8e80-f985b0e97368

📥 Commits

Reviewing files that changed from the base of the PR and between ac2faee and 6f39ab5.

📒 Files selected for processing (1)
  • src/jsc/bindings/NodeTLS.cpp

Comment thread src/jsc/bindings/NodeTLS.cpp

@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: 2

♻️ Duplicate comments (2)
src/jsc/bindings/NodeTLS.cpp (1)

168-173: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle sk_X509_push failures in both parse and dedupe paths.

Line 170 and Line 272 ignore push failures. That can leak cert refs and leave partial state installed on allocation failure.

Proposed fix
-    while (X509* x = PEM_read_bio_X509(bio, nullptr, [](char*, int, int, void*) -> int { return 0; }, nullptr)) {
-        sk_X509_push(out, x);
-        pushed++;
+    while (X509* x = PEM_read_bio_X509(bio, nullptr, [](char*, int, int, void*) -> int { return 0; }, nullptr)) {
+        if (!sk_X509_push(out, x)) {
+            X509_free(x);
+            OPENSSL_PUT_ERROR(SSL, ERR_R_MALLOC_FAILURE);
+            break;
+        }
+        pushed++;
     }
...
         if (seen.insert(cert).second) {
             X509_up_ref(cert);
-            sk_X509_push(deduped, cert);
+            if (!sk_X509_push(deduped, cert)) {
+                X509_free(cert); // undo up_ref
+                sk_X509_pop_free(deduped, X509_free);
+                freeParsed();
+                throwOutOfMemoryError(globalObject, scope);
+                return {};
+            }
         }
#!/bin/bash
rg -nC3 'sk_X509_push\(out, x\)|X509_up_ref\(cert\)|sk_X509_push\(deduped, cert\)' src/jsc/bindings/NodeTLS.cpp

Also applies to: 268-274

🤖 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 `@src/jsc/bindings/NodeTLS.cpp` around lines 168 - 173, The loops that call
PEM_read_bio_X509(...) then sk_X509_push(out, x) (and the dedupe path that calls
sk_X509_push(deduped, cert) and X509_up_ref(cert)) must check sk_X509_push's
return value and handle failures: if sk_X509_push returns 0 (failure)
immediately free the newly-read X509 (X509_free(x)) or drop the up-ref
(X509_free(cert) when you failed after X509_up_ref), free the BIO (BIO_free) and
clean up the entire stack being built (iterate and X509_free any already-pushed
certs and sk_X509_free the stack) before signalling/returning the error to avoid
leaking cert refs or leaving partial state; update the PEM_read_bio_X509 loop
and the dedupe block (where X509_up_ref and sk_X509_push are used) to perform
these checks and cleanup paths.
test/js/node/tls/node-tls-set-default-ca-certificates.test.ts (1)

262-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Assert verification-specific failure, not “any error code”.

Line 262 and Line 284 currently pass for any coded error (including non-TLS-path failures), which can mask regressions in the CA override behavior being tested.

Proposed tightening
-      } catch (err) {
-        assert(err.code && err.code !== "ERR_ASSERTION", "step1: " + err.message);
+      } catch (err) {
+        const code = String(err?.code || "");
+        assert(/UNABLE_TO_|SELF_SIGNED|CERT/i.test(code), "step1: expected TLS verify failure, got " + (err?.message || err));
       }
...
-      } catch (err) {
-        assert(err.code && err.code !== "ERR_ASSERTION", "step3: " + err.message);
+      } catch (err) {
+        const code = String(err?.code || "");
+        assert(/UNABLE_TO_|SELF_SIGNED|CERT/i.test(code), "step3: expected TLS verify failure, got " + (err?.message || err));
       }

Also applies to: 283-285

🤖 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/node/tls/node-tls-set-default-ca-certificates.test.ts` around lines
262 - 263, The current assertions (the expression using assert(err.code &&
err.code !== "ERR_ASSERTION", "step1: " + err.message); and the similar checks
later) accept any error with a code and thereby mask non-TLS failures; replace
these with assertions that err.code is one of the expected TLS/CA-related error
codes for this test (e.g., check membership in an explicit allowed set like
['ERR_SSL_HANDSHAKE_FAILURE', 'ERR_OSSL_*', 'ERR_TLS_CERT_ALTNAME_INVALID'] or
the concrete codes your runtime emits) and include the original err.message in
the failure text; update both occurrences (the assert expression and the later
similar block) to perform this exact-code check rather than just rejecting
ERR_ASSERTION.
🤖 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 `@packages/bun-usockets/src/crypto/openssl.c`:
- Around line 565-570: The code uses the implementation-defined sentinel (void
*)1 when storing ex_data on SSL_CTX; replace this with the address of a static
marker variable to be portable: add a static variable (e.g., a file-static char
or int) and pass its address into SSL_CTX_set_ex_data instead of (void *)1,
updating any checks that compare the stored pointer (related to
us_ctx_default_ca_ex_idx and ssl_context) to compare against that marker's
address.

In `@packages/bun-usockets/src/crypto/root_certs.cpp`:
- Around line 205-211: Replace the racy global bool has_user_root_certs with a
std::atomic<bool> and use relaxed atomics for the cheap racy read/write
semantics: update the declaration of has_user_root_certs to std::atomic<bool>,
include <atomic>, change the read in us_has_user_root_certs to
has_user_root_certs.load(std::memory_order_relaxed) and change any writes (the
assignment currently guarded by the mutex) to has_user_root_certs.store(...,
std::memory_order_relaxed) so the behavior is unchanged but well-defined.

---

Duplicate comments:
In `@src/jsc/bindings/NodeTLS.cpp`:
- Around line 168-173: The loops that call PEM_read_bio_X509(...) then
sk_X509_push(out, x) (and the dedupe path that calls sk_X509_push(deduped, cert)
and X509_up_ref(cert)) must check sk_X509_push's return value and handle
failures: if sk_X509_push returns 0 (failure) immediately free the newly-read
X509 (X509_free(x)) or drop the up-ref (X509_free(cert) when you failed after
X509_up_ref), free the BIO (BIO_free) and clean up the entire stack being built
(iterate and X509_free any already-pushed certs and sk_X509_free the stack)
before signalling/returning the error to avoid leaking cert refs or leaving
partial state; update the PEM_read_bio_X509 loop and the dedupe block (where
X509_up_ref and sk_X509_push are used) to perform these checks and cleanup
paths.

In `@test/js/node/tls/node-tls-set-default-ca-certificates.test.ts`:
- Around line 262-263: The current assertions (the expression using
assert(err.code && err.code !== "ERR_ASSERTION", "step1: " + err.message); and
the similar checks later) accept any error with a code and thereby mask non-TLS
failures; replace these with assertions that err.code is one of the expected
TLS/CA-related error codes for this test (e.g., check membership in an explicit
allowed set like ['ERR_SSL_HANDSHAKE_FAILURE', 'ERR_OSSL_*',
'ERR_TLS_CERT_ALTNAME_INVALID'] or the concrete codes your runtime emits) and
include the original err.message in the failure text; update both occurrences
(the assert expression and the later similar block) to perform this exact-code
check rather than just rejecting ERR_ASSERTION.
🪄 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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 6c01aa59-d7dd-4f76-a20e-b60ffb5b5d5a

📥 Commits

Reviewing files that changed from the base of the PR and between 6f39ab5 and 3b4ca72.

📒 Files selected for processing (8)
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/crypto/root_certs.cpp
  • packages/bun-usockets/src/crypto/root_certs_header.h
  • src/js/node/tls.ts
  • src/jsc/bindings/NodeTLS.cpp
  • test/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.js
  • test/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.js
  • test/js/node/tls/node-tls-set-default-ca-certificates.test.ts

Comment thread packages/bun-usockets/src/crypto/openssl.c
Comment thread packages/bun-usockets/src/crypto/root_certs.cpp Outdated
Comment thread test/js/node/tls/node-tls-set-default-ca-certificates.test.ts Outdated
Comment thread packages/bun-usockets/src/crypto/root_certs.cpp

@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 any remaining issues, but this replaces the process-wide TLS trust root set and reworks the shared X509_STORE lifecycle/locking in native code — definitely one for a human reviewer.

Extended reasoning...

Overview

Implements tls.setDefaultCACertificates() across four layers: a mutex-guarded process-global STACK_OF(X509)* override and invalidatable cached X509_STORE in root_certs.cpp; an SSL_CTX ex_data marker plus per-SSL verify-store refresh in openssl.c so cached HTTPS contexts pick up runtime changes; JSC host functions in NodeTLS.cpp for PEM parsing/dedup and snapshot read-back; and the JS wrapper in tls.ts. ~600 lines plus tests.

Security risks

This is the TLS trust-root surface. The override is intentionally process-global (diverging from Node's per-thread semantics), so one Worker can change verification for all. The PR went through several rounds fixing a UAF in the snapshot path, a data race on has_user_root_certs, an X509 refcount leak, and sk_X509_push allocation-failure handling — all now addressed. The remaining design (mutex + atomic flag + SSL_set0_verify_cert_store per-SSL) looks sound to me, but the blast radius of a mistake here is silent mis-verification of TLS peers.

Level of scrutiny

High. Native crypto code with manual BoringSSL refcounting, cross-thread shared mutable state, and a behavioural change to how every default-CA TLS connection builds its trust store (the shared store moved from std::call_once to a mutex-guarded rebuildable cache). The us_internal_ssl_attach change also runs on the hot per-connection path.

Other factors

All prior bot feedback (mine and CodeRabbit's) is resolved. Test coverage is solid — input validation, dedup, rollback-on-error, Worker visibility, and an end-to-end fail→install→succeed→clear→fail HTTPS sequence, plus two upstream Node parallel tests. No outstanding human review comments. Still, the combination of security-critical + concurrency + manual memory management + intentional Node.js divergence is exactly the profile that warrants human sign-off.

@robobun

robobun commented May 12, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Implementation complete at e2136fa. All review threads resolved; claude[bot]'s final review found no remaining issues.

CI

282/286 jobs pass. The 4 failures are all on tests unrelated to this diff (which only touches node:tls / TLS trust-store code paths):

Lane Failing test Relation to this PR
darwin-aarch64-14 test/js/bun/s3/s3-storage-class.test.ts S3 — none
windows-x64-2019 test-http-should-emit-close-when-connection-is-aborted.ts plain HTTP fetch() abort — none
windows-x64-baseline-2019 same HTTP-abort timeout + bun-install-registry.test.ts hoisting (passed on retry) none
windows-aarch64-11 same HTTP-abort timeout + same install-registry flake none

The HTTP-abort test timed out 4/4 attempts on 3 Windows lanes in two consecutive builds (53807 and 53813), so re-rolling is unlikely to help.

The new tests added by this PR (node-tls-set-default-ca-certificates.test.ts, test-tls-set-default-ca-certificates-{basic,array-buffer}.js) passed on every lane that ran them, including linux-x64-asan.

Ready for maintainer review / merge.

tls.setDefaultCACertificates(certs) replaces the default CA trust store
used for TLS client verification when no explicit 'ca' option is given.
After calling it, tls.getCACertificates('default') returns exactly the
supplied (deduplicated) certificates, and new TLS/HTTPS connections
verify against that set instead of the bundled Mozilla roots.

- root_certs.cpp: mutex-guarded process-global user cert override that
  us_get_default_ca_store() honours exclusively when present; the cached
  shared store is invalidated on each setDefaultCACertificates() call.
  has_user_root_certs is atomic for the lock-free fast-path check.
- openssl.c: SSL_CTXs built against the default CA set (request_cert,
  no explicit ca) are tagged via ex_data so us_internal_ssl_attach()
  refreshes the per-SSL verify store after the defaults change — the
  cached fetch/https CTX picks up the override without a rebuild.
- NodeTLS.cpp: resetRootCertStore parses PEM strings/ArrayBufferViews
  (multi-cert bundles supported), deduplicates by X509 identity, and
  installs the set; getUserRootCertificates returns an up-ref'd PEM
  snapshot, or undefined when no override is installed.
- tls.ts: validates input (ERR_INVALID_ARG_TYPE), queries the native
  override state so Workers always report the store their connections
  actually verify against.

Fixes #24340
Fixes #13868
@robobun robobun force-pushed the farm/06cd3e87/tls-set-default-ca-certificates branch from e2136fa to 32d9894 Compare May 25, 2026 23:07

@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 issues found in this pass, but this touches the process-wide TLS trust-root path (mutex-guarded shared X509_STORE, per-SSL verify-store override in us_internal_ssl_attach, intentional process-global vs Node's thread-local semantics) — worth a human sign-off before merge.

Extended reasoning...

Overview

This PR implements tls.setDefaultCACertificates() (Node.js v24.5.0). It spans:

  • packages/bun-usockets/src/crypto/root_certs.cpp — replaces the std::call_once-cached shared default X509_STORE with a mutex-guarded, invalidatable cache; adds process-global user_root_certs override with std::atomic<bool> fast-path flag and a snapshot-under-lock duplicator.
  • packages/bun-usockets/src/crypto/openssl.c — new SSL_CTX ex_data marker so us_internal_ssl_attach() can swap the verify store per-SSL when the default set has changed under a long-lived cached CTX (fetch/https).
  • src/jsc/bindings/NodeTLS.cpp — ~210 new lines: PEM parsing with rollback, X509_cmp-based dedup via std::set, manual sk_X509_* refcounting, two new host functions.
  • src/js/node/tls.ts — JS wrapper, input validation, per-VM cache invalidation that defers to native for cross-Worker visibility.
  • New Bun test, two upstream Node parallel tests, and common/tls.js helpers.

Security risks

This is squarely security-sensitive: it rewires how the default TLS trust root set is selected for every client connection that doesn't pass an explicit ca. Specific concerns a human should weigh:

  • The shared default store moves from immutable-once to mutex-guarded mutable. Every TLS client attach now takes the fast-path branch in us_internal_ssl_attach; correctness depends on the ex_data marker only being set when no user ca was supplied.
  • Manual BoringSSL refcounting (X509_up_ref / sk_X509_pop_free / X509_STORE_add_cert) across two ownership domains; the review cycle already caught one UAF and one leak here.
  • Intentional divergence from Node.js: override is process-global, not per-Worker (thread_local). The rationale (SSL attach runs off the JS thread) is documented and was discussed in-thread, but it's a design decision that affects multi-Worker isolation and should be ratified by a maintainer.

Level of scrutiny

High. This is production-critical crypto/TLS plumbing in C/C++ with concurrency, not a config tweak. The change also affects the hot path for all default-CA TLS connections (the shared-store cache mechanism), not just callers of the new API.

Other factors

  • The PR went through ~7 fix commits during review (UAF snapshot, per-VM cache desync, size_tint cast, sk_X509_push return checks, std::atomic<bool>, static-address marker, redundant X509_up_ref leak, test hostname). All raised threads are resolved and the current diff reflects the fixes.
  • Test coverage is good: input validation, round-trip, dedup, ArrayBufferView variants, invalid-PEM rollback, Worker visibility, and an end-to-end fail→install→succeed→clear→fail HTTPS sequence; plus two unmodified upstream Node tests.
  • CI: 282/286 passing; the 4 failures are documented as unrelated flakes.
  • No CODEOWNERS check performed here, but TLS/crypto code in this repo typically has designated owners.

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (post-rebase)

The diff itself is green — the new TLS tests (node-tls-set-default-ca-certificates.test.ts, test-tls-set-default-ca-certificates-{basic,array-buffer}.js) pass on every lane in every run, including linux-x64-asan, all Windows lanes, darwin, and musl.

Each of the last three builds failed on a different, unrelated infra/network flake:

Build Only failing lane Cause
53813 (pre-rebase) windows ×3, darwin test-http-should-emit-close-when-connection-is-aborted.ts timeout (plain-HTTP abort), s3-storage-class, bun-install-registry hoisting
58058 (rebased) darwin-aarch64 grpc-js/test-resolver — external DNS lookup of network-tools.com failed
58077 (re-roll) alpine-3.23 musl baseline mysql_plain Docker service never became healthy → MySQL-dependent tests failed to start

None of these touch node:tls, the CA store, or anything in this diff. I've used my one CI re-roll; this needs a maintainer to merge past the flake (or rerun the single failed job).

Review status: all CodeRabbit/claude-bot threads resolved; final automated review found no remaining issues.

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.

node:tls does not implement setDefaultCACertificates Ability to set NODE_EXTRA_CA_CERTS in standalone executable

1 participant