node:tls: implement setDefaultCACertificates()#30570
Conversation
|
Updated 7:23 PM PT - May 25th, 2026
❌ @robobun, your commit b54356c has 2 failures in
🧪 To try this PR locally: bunx bun-pr 30570That installs a local version of the PR into your bun-30570 --bun |
|
Found 1 issue this PR may fix:
🤖 Generated with Claude Code |
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
WalkthroughAdds 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. ChangesUser-Overridable Default CA Certificates
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (7)
packages/bun-usockets/src/crypto/root_certs.cpppackages/bun-usockets/src/crypto/root_certs_header.hsrc/js/node/tls.tssrc/jsc/bindings/NodeTLS.cppsrc/jsc/bindings/NodeTLS.htest/js/node/test/common/tls.jstest/js/node/tls/node-tls-set-default-ca-certificates.test.ts
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
src/jsc/bindings/NodeTLS.cpp (1)
277-300:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winTake a stable snapshot before walking
user_root_certs.This still iterates the raw
STACK_OF(X509)*returned byus_get_user_root_certs()with no synchronization. A concurrenttls.setDefaultCACertificates()can free or replace that stack mid-loop and turnsk_X509_value/PEM_write_bio_X509into 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
📒 Files selected for processing (1)
src/jsc/bindings/NodeTLS.cpp
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
src/jsc/bindings/NodeTLS.cpp (1)
151-156:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGuard the
size_t→intcast beforeBIO_new_mem_buf().
BIO_new_mem_buf()takes a signed length. Ifdata.size() > INT_MAX, the cast wraps negative and BoringSSL/OpenSSL switches to NUL-terminated semantics, so an oversizedArrayBufferViewcan 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
📒 Files selected for processing (4)
packages/bun-usockets/src/crypto/root_certs.cpppackages/bun-usockets/src/crypto/root_certs_header.hsrc/jsc/bindings/NodeTLS.cpptest/js/node/tls/node-tls-set-default-ca-certificates.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
src/jsc/bindings/NodeTLS.cpp (1)
162-165:⚠️ Potential issue | 🟠 Major | ⚡ Quick winCheck
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 extraX509_up_ref()while still committing a truncated CA set. Treat a falseysk_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
📒 Files selected for processing (1)
src/jsc/bindings/NodeTLS.cpp
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (2)
src/jsc/bindings/NodeTLS.cpp (1)
168-173:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHandle
sk_X509_pushfailures 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.cppAlso 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 winAssert 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
📒 Files selected for processing (8)
packages/bun-usockets/src/crypto/openssl.cpackages/bun-usockets/src/crypto/root_certs.cpppackages/bun-usockets/src/crypto/root_certs_header.hsrc/js/node/tls.tssrc/jsc/bindings/NodeTLS.cpptest/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.jstest/js/node/test/parallel/test-tls-set-default-ca-certificates-basic.jstest/js/node/tls/node-tls-set-default-ca-certificates.test.ts
There was a problem hiding this comment.
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.
StatusImplementation complete at e2136fa. All review threads resolved; claude[bot]'s final review found no remaining issues. CI282/286 jobs pass. The 4 failures are all on tests unrelated to this diff (which only touches
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 ( 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
e2136fa to
32d9894
Compare
There was a problem hiding this comment.
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 thestd::call_once-cached shared defaultX509_STOREwith a mutex-guarded, invalidatable cache; adds process-globaluser_root_certsoverride withstd::atomic<bool>fast-path flag and a snapshot-under-lock duplicator.packages/bun-usockets/src/crypto/openssl.c— newSSL_CTXex_data marker sous_internal_ssl_attach()can swap the verify store per-SSLwhen 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 viastd::set, manualsk_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.jshelpers.
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 usercawas 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_t→intcast,sk_X509_pushreturn checks,std::atomic<bool>, static-address marker, redundantX509_up_refleak, 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.
CI status (post-rebase)The diff itself is green — the new TLS tests ( Each of the last three builds failed on a different, unrelated infra/network flake:
None of these touch Review status: all CodeRabbit/claude-bot threads resolved; final automated review found no remaining issues. |
What does this PR do?
Implements
tls.setDefaultCACertificates(certs)(added in Node.js v24.5.0).Fixes #24340
Fixes #13868
Repro
Before:
TypeError: tls.setDefaultCACertificates is not a functionAfter: replaces the default trust store with the system CA set.
How
packages/bun-usockets/src/crypto/root_certs.cpp— add a mutex-guarded user-overrideSTACK_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 fromstd::call_onceto a mutex sosetDefaultCACertificatescan invalidate it for subsequent connections; in-flight SSL*s keep their ownX509_STOREreference viaSSL_set0_verify_cert_store.packages/bun-usockets/src/crypto/openssl.c—https.request()/fetch()build their HTTPSSSL_CTXonce on the HTTP thread withrequest_cert=1and noca, 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) sous_internal_ssl_attach()can override the verify store per-SSL with the current shared default wheneversetDefaultCACertificateshas been called. CTXs built with an explicitcaare left untouched.src/jsc/bindings/NodeTLS.cpp—resetRootCertStoreaccepts an array of PEM strings /ArrayBufferViews (each entry may contain multiple certs), deduplicates byX509_cmpidentity, and installs the result.getUserRootCertificatesreturnsundefinedwhen 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.ts—setDefaultCACertificates(certs)validatescertsis anArrayofstring | ArrayBufferView(ERR_INVALID_ARG_TYPEotherwise), 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
ERR_CRYPTO_OPERATION_FAILEDand leaves the current defaults untouched.getCACertificates('bundled')/'system'/'extra'remain unchanged.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.tscovers: existence, input validation (non-array / bad element types), round-tripping throughgetCACertificates('default'), deduplication,Buffer/Uint8Array/DataViewinputs, 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.jstest/js/node/test/parallel/test-tls-set-default-ca-certificates-array-buffer.jsAlso added
assertEqualCerts/includesCerthelpers totest/js/node/test/common/tls.jsto 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_rawso explicitca/ca_file_nameoptions now populate the SSL_CTX's own private store instead of callingus_get_default_ca_store(). This composes cleanly with this PR — the default-store override and the ex_data refresh marker only apply to therequest_cert-without-capath, which still uses the process default store. The only manual conflict was the comment block aboveus_get_shared_default_ca_store()inroot_certs.cpp.