Skip to content

tls: read NODE_EXTRA_CA_CERTS from the env map that has the .env files - #43002

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/e41356e5/extra-ca-certs-dotenv
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/e41356e5/extra-ca-certs-dotenv

Conversation

@robobun

@robobun robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • NODE_EXTRA_CA_CERTS set in a .env file shows in process.env, but tls.getCACertificates("extra") is empty and fetch() to a server that the file's CA issued fails with DEPTH_ZERO_SELF_SIGNED_CERT (or UNABLE_TO_VERIFY_LEAF_SIGNATURE). The same value in the real environment works.
  • Cause: us_get_root_extra_cert_instances() in packages/bun-usockets/src/crypto/root_certs.cpp:178 reads getenv("NODE_EXTRA_CA_CERTS"). Bun loads .env files into its own env map, not into the C environment. tls.ts:1727 read process.env instead, so the JS list and the native store could also disagree after a runtime write.

Fix

  • New src/uws/root_certs.rs keeps one process-wide snapshot of the path in a OnceLock. VirtualMachine::load_extra_env_and_source_code_printer fills it from the env map at VM boot, after the .env files are loaded and before any TLS context exists. Without that call (bun install), the first read falls back to the process environment.
  • The C++ loader calls Bun__Node__extraCACertsPath() instead of getenv. tls.ts always appends the native extra list, which is empty when the variable is unset.
  • Correct because Node reads NODE_EXTRA_CA_CERTS once at startup and ignores later process.env writes. The snapshot gives the C++ store and tls.getCACertificates() the same single source. A worker with a custom env cannot change it, because only the first call counts.
  • Verified: test/js/node/tls/test-node-extra-ca-certs.test.ts (three new tests, two fail on 1.4.3). Also test-use-system-ca, node-tls-cert, node-tls-context, node-tls-root-certs-concurrent-init, fetch.tls, issue 24339, and the Node test-tls-env-* and test-tls-get-ca-certificates-* files.

Background

  • Bun's dotenv loader (src/dotenv/env_loader.rs) holds the process environment plus the .env files in one map. process.env is built from that map. A .env value never overrides a real environment variable.
  • The default CA store is built once per process in us_get_default_ca_store() from the bundled roots, the OpenSSL default file, the NODE_EXTRA_CA_CERTS file, and (with --use-system-ca) the OS store. fetch(), tls.connect(), and Bun.serve share it.
  • NODE_TLS_REJECT_UNAUTHORIZED and the proxy variables already take effect from .env through the same map. This change gives NODE_EXTRA_CA_CERTS the same treatment.
Notes

Scope: the issue also describes NODE_USE_SYSTEM_CA from .env. That variable is left as is. #34424 changes its plumbing to a per-thread model, and this PR does not touch us_should_use_system_ca(), Bun__Node__UseSystemCA, or the process.env.NODE_USE_SYSTEM_CA check in tls.ts.

bun install does not take the .env snapshot on purpose. #42597 keeps the project's .env out of the install's HTTP client for proxy and TLS settings, and the trust store follows that rule. The install reads the variable from the process environment, as before.

maybeWarnAboutExtraCACerts in tls.ts still reads process.env for the missing-file warning. #42991 replaces that function with the native load, which reads the snapshot.

Manual probes with the debug build: a worker started with env: { NODE_EXTRA_CA_CERTS: ... } sees the main thread's snapshot. bun install in a directory with NODE_EXTRA_CA_CERTS in .env runs unchanged.

Self-review: the review asked to split the NODE_USE_SYSTEM_CA half out (done), to wire bun install (not done, see above), and to move the warning to the native path (left to #42991).

The native CA loader read the variable with getenv(), which only sees
the C environment. Bun loads .env files into its own env map, so a
value set there showed in process.env but never reached the trust
store. tls.getCACertificates() read process.env, so the JS view and the
native view could disagree in both directions.

bun_uws::root_certs now holds one process-wide snapshot of the path,
taken from the env map when the runtime VM boots, and falls back to the
process environment when nothing takes it. The C++ loader and tls.ts
read that snapshot. As in Node, a later write to process.env changes
nothing. bun install keeps reading the process environment only.
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Review paused — included plan limit reached

Keep your review moving with free on-demand reviews.

  • Run this review for free

On-demand reviews are free for the next 4 days.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Promotion and pricing details

On-demand reviews are free for the next 4 days. After that, they cost $0.25 per reviewed file.

Review limit details

Or wait 38 seconds for your next included review.

Check out review usage here.

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 4fa5c230-051b-40d7-8f5b-4e66997214b3

📥 Commits

Reviewing files that changed from the base of the PR and between b64b630 and 1d8a119.

📒 Files selected for processing (7)
  • packages/bun-usockets/src/crypto/root_certs.cpp
  • src/bun_core/env_var.rs
  • src/js/node/tls.ts
  • src/jsc/VirtualMachine.rs
  • src/uws/lib.rs
  • src/uws/root_certs.rs
  • test/js/node/tls/test-node-extra-ca-certs.test.ts

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

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 9:25 PM PT - Sep 16th, 2026

@robobun, your commit 1d8a119 is building: #116852

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

The automated review hit its plan limit on this run and posted no findings. The diff is unchanged and ready for review. CI is in progress.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Intentional security thing.

@robobun

robobun commented Sep 17, 2026

Copy link
Copy Markdown
Collaborator Author

Understood. A project's .env must not add trust anchors, so the native store keeps reading the process environment only. Leaving this closed.

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked two things and ruled them out: the runtime boot ordering (load_env runs in load_extra_env_and_source_code_printer before any script executes, so no SSL_CTX exists yet on the bun run/bun test paths), and Worker env overrides (web_worker.rs:761 also calls load_extra_env_and_source_code_printer, but the OnceLock keeps the main thread's snapshot, which matches Node's read-once semantics).

Extended reasoning...

Findings were reported inline (bundler-CLI and bun install paths never call load_env, plus a test-concurrency nit), so this body only records what else was examined. I verified from the diff that the OnceLock<Option<ZBox>> returns a process-lifetime NUL-terminated pointer to C++, that path_of treats empty as unset (replacing the dropped C++ empty-string check), and that all VM entry points including workers route through load_extra_env_and_source_code_printer, so first-write-wins yields the main-thread value. Nothing on the runtime path constructs a TLS context before that call. The remaining gaps are the ones already posted inline.

2 verified lower-impact observations (convention, logging or cleanup points) were not posted.

// Node reads NODE_EXTRA_CA_CERTS once at startup. Bun loads .env files into
// process.env, so a value set there must reach the native trust store the
// same way a value from the real environment does.
describe("NODE_EXTRA_CA_CERTS from a .env file", () => {

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.

🟡 nit (optional): the three new subprocess tests run serially, so the file takes three debug-build startups back to back instead of overlapping them. test/CLAUDE.md asks that independent process-spawning tests be made concurrent unless that is hard; these share no state (each has its own tempDir and env). Fix: use describe.concurrent("NODE_EXTRA_CA_CERTS from a .env file", ...) (or test.concurrent) for the block at test/js/node/tls/test-node-extra-ca-certs.test.ts:177.

Extended reasoning...

Each of the three tests spawns a fresh bun process that starts a TLS server, fetches, and exits. Under a debug+ASAN build every startup is slow, and the serial describe at line 177 adds their wall time together. Nothing in the tests shares state: separate tempDir per test, env built per test, port 0. Switching to describe.concurrent overlaps them with no behavior change. The rest of the file predates this PR and is also serial, which is why this is only a nit and not a blocker.

Verification: nit. Triggering condition: every run of this file (CI debug/ASAN lanes in particular). The new block at test/js/node/tls/test-node-extra-ca-certs.test.ts:177 is describe("NODE_EXTRA_CA_CERTS from a .env file", () => { with three plain test(...) calls (lines 197, 221, 245), each doing await using dir = tempDir(...) and await using proc = spawn({ cmd: [bunExe(), "main.js"], ... }).…

Comment thread src/uws/root_certs.rs
/// Only the first call counts.
pub fn load_env(extra_ca_certs: Option<&[u8]>) {
let _ = EXTRA_CA_CERTS.set(path_of(extra_ca_certs));
}

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.

🟣 pre-existing, not blocking: Developers running bun build with a macro or plugin that fetches over HTTPS still cannot trust a CA given via .env, even though the bundler loads that .env and shows the path in process.env. Nothing on the bundler-CLI path calls load_env, so src/uws/root_certs.rs:28 falls back to the process environment and the macro's fetch fails with DEPTH_ZERO_SELF_SIGNED_CERT. The PR fixes one entry point of a multi-entry class. Fix: seed the snapshot from the bundler's env map wherever a VM is created for macros/plugins (jsc_hooks macro context, build_command), or state the exclusion in the PR alongside bun install.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Extended reasoning...

User runs bun build ./entry.ts in a project whose .env sets NODE_EXTRA_CA_CERTS; a macro (import { data } from './m.ts' with { type: 'macro' }) calls fetch() against an internal HTTPS host signed by that CA. build_command.rs:451 sets the bundler env behavior from the CLI so run_env_loader (transpiler.rs:773) loads process env plus .env into the bundler map; process.env inside the macro VM shows the path. No caller on this path invokes bun_uws::root_certs::load_env (callers: run_command.rs:1087/1215, test_command.rs:1963, repl_command.rs:128, bake/production.rs:195, web_worker.rs:761 only). The macro's fetch creates the HTTPS context lazily; root_certs.cpp:182 calls Bun__Node__extraCACertsPath, root_certs.rs:28 initialises from env_var::NODE_EXTRA_CA_CERTS::get() (process env only) and returns null; verification fails. The finder classed this pre_existing because the base used getenv there too; that is true, but REVIEW.md asks that every sibling entry point receiving the same fix be covered or explicitly excluded, and the PR notes exclude only bun install. Population: bundler users…

Verification: pre-existing (the base's getenv() already misses .env on this path; the PR fixes bun run/bun test/workers but not the bun build CLI macro VM). Trigger: bun build ./entry.ts in a project whose .env sets NODE_EXTRA_CA_CERTS, with a macro that fetch()es an HTTPS host issued by that CA. Mechanism verified: - The only callers of load_extra_env_and_source_code_printer (the sole caller of…

Comment thread src/uws/root_certs.rs
/// The path as a NUL-terminated string that lives for the process, or null when the variable is unset or empty.
#[unsafe(no_mangle)]
extern "C" fn Bun__Node__extraCACertsPath() -> *const c_char {
match EXTRA_CA_CERTS.get_or_init(|| path_of(env_var::NODE_EXTRA_CA_CERTS::get())) {

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.

🟣 pre-existing, not blocking: pre-existing: bun install still ignores a NODE_EXTRA_CA_CERTS set in the project's .env, so installs from a private registry keep failing while bun run now works. Nothing on the install path calls load_env, so Bun__Node__extraCACertsPath at src/uws/root_certs.rs:28 falls back to the C environment. The PR note says #42597 keeps .env out of the install's proxy and TLS settings, but PackageManager.rs:1914 loads the default .env files and PackageManager.rs:897/:901 read proxy and NODE_TLS_REJECT_UNAUTHORIZED from that map. Fix: snapshot the install env map too, e.g. call bun_uws::root_certs::load_env(env.get(b"NODE_EXTRA_CA_CERTS")) right after env.load(...) in PackageManager init, so every command that honors .env for TLS honors it for the CA file.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Extended reasoning...

The PR's stated reason for leaving bun install on the process environment is that the install's HTTP client already excludes .env for proxy and TLS settings. The code says otherwise. PackageManager.rs:1895-1919 allocates the install's env loader, calls env.load_process() and then env.load(&env_probe_keys, &[], DotEnvFileSuffix::Production, false); the last argument is skip_default_env = false, so env_loader.rs:617-618 loads .env.production.local, .env.local, .env.production and .env into the map. PackageManager.rs:897 http_proxy and PackageManager.rs:901 tls_reject_unauthorized read from that map (env_loader.rs:320 reads NODE_TLS_REJECT_UNAUTHORIZED from self.get). NetworkTask.rs:685 and :936 apply pm.tls_reject_unauthorized() to every registry request. So for the install command, .env already drives proxy and certificate verification, which is exactly the class of setting the note claims it excludes. After this PR, Bun__Node__extraCACertsPath (root_certs.rs:28) runs get_or_init with env_var::NODE_EXTRA_CA_CERTS::get() because no install code path calls…

Verification: pre-existing; acknowledged in diff: the PR description's Notes say "bun install does not take the .env snapshot on purpose. #42597 keeps the project's .env out of the install's HTTP client for proxy and TLS settings, and the trust store follows that rule" — the first half holds, the stated justification does not. Trigger: bun install from a registry whose CA is only reachable via…

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