From 1f8eaafb6bdc0835125f963b769e1fbcc771cd22 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:16:39 +0000 Subject: [PATCH 1/7] tls: trust ca and caFile together; throw on cert+certFile / key+keyFile us_ssl_ctx_build_raw loaded trust anchors with `if (ca_file_name) ... else if (ca ...)`, so when both were given the inline `ca` was silently dropped and never parsed. The same shadowing applied to bun install: a bunfig or npmrc `cafile` made `--ca` (and bunfig `ca`) a no-op. Collect the file's certificates and the inline entries into one list and install them together. An invalid inline `ca` is now rejected even when a `caFile` is present. cert+certFile and key+keyFile cannot be merged (one identity per key type), so SSLConfig.from_js throws instead of serving whichever the builder loads. --- packages/bun-usockets/src/crypto/openssl.c | 93 +++++++++------ src/runtime/socket/SSLConfig.rs | 13 ++ test/cli/install/bun-install-registry.test.ts | 50 ++++++++ test/js/bun/http/bun-serve-ssl.test.ts | 112 +++++++++++++----- 4 files changed, 200 insertions(+), 68 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index cb5d8706a08a..2b8319c4f7cf 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -992,16 +992,11 @@ static X509_STORE *us_ssl_ctx_get_own_cert_store(SSL_CTX *ctx) { return store; } -/* The `ca` option: every CERTIFICATE block of `content` as DER into `certs`. User-supplied certificates are validated - * with a full parse here so a bad one fails the context rather than a later handshake; the parsed object is dropped and - * the store keeps the DER lazily. Returns the number appended, or -1 for content that is not PEM or whose first - * certificate block does not decode or parse. As with a PEM_read_bio_X509 loop, a PEM document with no certificates - * (Node's tests pass a private key) counts as zero, and blocks after one that fails are ignored. */ -static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_BUFFER) *certs) { - if (content == NULL) return 0; - ERR_clear_error(); - BIO *in = BIO_new_mem_buf(content, strlen(content)); - if (in == NULL) return -1; +/* Every CERTIFICATE block readable from `in` as DER into `certs`. User-supplied certificates are validated with a full + * parse here so a bad one fails the context rather than a later handshake; the parsed object is dropped and the store + * keeps the DER lazily. Returns the number appended, or -1 when the first certificate block does not decode or parse. + * As with a PEM_read_bio_X509 loop, blocks after one that fails are ignored. */ +static int us_collect_ca_bio(SSL_CTX *ctx, BIO *in, STACK_OF(CRYPTO_BUFFER) *certs) { int count = 0; for (;;) { uint8_t *der = NULL; @@ -1027,6 +1022,18 @@ static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_ } count++; } + return count; +} + +/* The `ca` option: the CERTIFICATE blocks of `content` into `certs`. Returns the number appended, or -1 for content that + * is not PEM or whose first certificate block does not decode or parse. A PEM document with no certificates (Node's + * tests pass a private key) counts as zero. */ +static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_BUFFER) *certs) { + if (content == NULL) return 0; + ERR_clear_error(); + BIO *in = BIO_new_mem_buf(content, strlen(content)); + if (in == NULL) return -1; + int count = us_collect_ca_bio(ctx, in, certs); BIO_free(in); if (count == 0) { unsigned long pem_err = ERR_peek_last_error(); @@ -1039,6 +1046,28 @@ static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_ return count; } +/* The `caFile` option: the file's CERTIFICATE blocks into `certs`, as us_collect_ca_pem does for an inline `ca` entry, + * except that a file holding no certificate is an error, as it was for SSL_load_client_CA_file. Returns the number + * appended, or -1 with *err set: LOAD_CA_FILE when the file cannot be opened, INVALID_CA_FILE for its contents. */ +static int us_collect_ca_file(SSL_CTX *ctx, const char *path, STACK_OF(CRYPTO_BUFFER) *certs, + enum create_bun_socket_error_t *err) { + ERR_clear_error(); + BIO *in = BIO_new_file(path, "rb"); + if (in == NULL) { + ERR_clear_error(); + *err = CREATE_BUN_SOCKET_ERROR_LOAD_CA_FILE; + return -1; + } + int count = us_collect_ca_bio(ctx, in, certs); + BIO_free(in); + ERR_clear_error(); + if (count <= 0) { + *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; + return -1; + } + return count; +} + static int us_crypto_buffer_cmp(const CRYPTO_BUFFER *const *a, const CRYPTO_BUFFER *const *b) { size_t al = CRYPTO_BUFFER_len(*a), bl = CRYPTO_BUFFER_len(*b); if (al != bl) return al < bl ? -1 : 1; @@ -1348,46 +1377,34 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * everywhere downstream — no special "owner" path. */ ssl_ctx_drop_passphrase(ssl_context); - if (options.ca_file_name) { + if (options.ca_file_name || (options.ca && options.ca_count > 0)) { /* An explicit CA replaces the default trust store (Node.js semantics): - * chains must validate exclusively against the supplied CAs. The SSL_CTX - * already owns a fresh, empty X509_STORE from SSL_CTX_new(), so - * SSL_CTX_load_verify_locations below populates only the user's CAs. */ - STACK_OF(X509_NAME) *ca_list = SSL_load_client_CA_file(options.ca_file_name); - if (ca_list == NULL) { - *err = CREATE_BUN_SOCKET_ERROR_LOAD_CA_FILE; - ssl_ctx_build_fail(ssl_context); - return NULL; - } - SSL_CTX_set_client_CA_list(ssl_context, ca_list); - us_ex_idx_ensure(); - SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); - if (SSL_CTX_load_verify_locations(ssl_context, options.ca_file_name, NULL) != 1) { - *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; - ssl_ctx_build_fail(ssl_context); - return NULL; - } - SSL_CTX_set_verify(ssl_context, - options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) - : SSL_VERIFY_PEER, - us_verify_callback); - - } else if (options.ca && options.ca_count > 0) { + * chains must validate exclusively against the supplied CAs, so they go + * into the SSL_CTX's own, initially-empty X509_STORE from SSL_CTX_new() — + * otherwise a server doing mTLS with `ca: [internalCA]` would also accept + * any client certificate that chains to a public root. `caFile` and the + * inline `ca` entries are one trust list: both are collected here and + * installed together, rather than the file shadowing the inline CAs. */ us_ex_idx_ensure(); SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); - /* As above: user CAs only, into the SSL_CTX's own initially-empty store — - * otherwise a server doing mTLS with `ca: [internalCA]` would also accept - * any client certificate that chains to a public root. */ X509_STORE *cert_store = SSL_CTX_get_cert_store(ssl_context); STACK_OF(CRYPTO_BUFFER) *ca_certs = sk_CRYPTO_BUFFER_new_null(); + enum create_bun_socket_error_t ca_err = options.ca && options.ca_count > 0 ? CREATE_BUN_SOCKET_ERROR_INVALID_CA + : CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; + if (ca_certs != NULL && options.ca_file_name && + us_collect_ca_file(ssl_context, options.ca_file_name, ca_certs, &ca_err) < 0) { + sk_CRYPTO_BUFFER_pop_free(ca_certs, CRYPTO_BUFFER_free); + ca_certs = NULL; + } for (unsigned int i = 0; ca_certs != NULL && i < options.ca_count; i++) { if (us_collect_ca_pem(ssl_context, options.ca[i], ca_certs) < 0) { + ca_err = CREATE_BUN_SOCKET_ERROR_INVALID_CA; sk_CRYPTO_BUFFER_pop_free(ca_certs, CRYPTO_BUFFER_free); ca_certs = NULL; } } if (ca_certs == NULL || !us_install_ca_buffers(ssl_context, cert_store, ca_certs)) { - *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA; + *err = ca_err; ssl_ctx_build_fail(ssl_context); return NULL; } diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 3823142abe5f..46c09e7f3125 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -219,11 +219,24 @@ impl SSLConfigFromJs for SSLConfig { || result.session_timeout != 0 || result.allow_partial_trust_chain; + // The SSL_CTX holds one identity per key type, so a file and an inline + // value for the same slot cannot both be used; refuse instead of + // serving whichever one the builder happens to load. if let Some(key_file) = generated.key_file.as_ref() { + if result.key.is_some() { + return Err(global.throw_invalid_arguments(format_args!( + "TLSOptions.key and TLSOptions.keyFile cannot both be set. Pass the file as key: Bun.file(path) instead." + ))); + } result.key_file_name = handle_path(global, "keyFile", key_file)?; result.requires_custom_request_ctx = true; } if let Some(cert_file) = generated.cert_file.as_ref() { + if result.cert.is_some() { + return Err(global.throw_invalid_arguments(format_args!( + "TLSOptions.cert and TLSOptions.certFile cannot both be set. Pass the file as cert: Bun.file(path) instead." + ))); + } result.cert_file_name = handle_path(global, "certFile", cert_file)?; result.requires_custom_request_ctx = true; } diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index dc76901d5a69..ee2513763180 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -232,6 +232,56 @@ describe("certificate authority", () => { expect(err).not.toContain("error:"); expect(await exited).toBe(0); }); + test("--ca is trusted in addition to a cafile from bunfig", async () => { + // The registry's certificate is only in --ca; the bunfig cafile holds an + // unrelated CA. Both are one trust list, so the CLI flag is not shadowed + // by the config file. + using server = Bun.serve({ + port: 0, + fetch: mockRegistryFetch(), + ...tls, + }); + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.1.1", + dependencies: { + "no-deps": `https://localhost:${server.port}/no-deps-1.0.0.tgz`, + }, + }), + ), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: `https://localhost:${server.port}/`, + cafile: "unrelated-ca.pem", + }, + }), + ), + write( + join(packageDir, "unrelated-ca.pem"), + file(join(import.meta.dir, "..", "..", "js", "node", "tls", "fixtures", "ca1-cert.pem")), + ), + ]); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install", "--ca", tls.cert], + cwd: packageDir, + stderr: "pipe", + stdout: "pipe", + env, + }); + const out = await stdout.text(); + const err = await stderr.text(); + expect(err).not.toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(err).not.toContain("error:"); + expect(out).toContain("+ no-deps@"); + expect(await exited).toBe(0); + }); test(`non-existent --cafile`, async () => { await write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0", "dependencies": { "no-deps": "1.1.1" } })); diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 9042e56d61df..30dd55aa6358 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -153,40 +153,92 @@ describe("Bun.serve SSL validations", () => { } }); -describe("Bun.serve per-serverName client certificate policy", () => { - const tlsFixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); - const serverKey = readFileSync(join(tlsFixtures, "agent10-key.pem"), "utf8"); - const serverCert = readFileSync(join(tlsFixtures, "agent10-cert.pem"), "utf8"); - // ec10 chains to ca5; agent1 chains to ca1 and is not trusted by ca5. - const clientCa = readFileSync(join(tlsFixtures, "ca5-cert.pem"), "utf8"); - const trustedClient = { - key: readFileSync(join(tlsFixtures, "ec10-key.pem"), "utf8"), - cert: readFileSync(join(tlsFixtures, "ec10-cert.pem"), "utf8"), - }; - const untrustedClient = { - key: readFileSync(join(tlsFixtures, "agent1-key.pem"), "utf8"), - cert: readFileSync(join(tlsFixtures, "agent1-cert.pem"), "utf8"), - }; +const tlsFixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); +const serverKey = readFileSync(join(tlsFixtures, "agent10-key.pem"), "utf8"); +const serverCert = readFileSync(join(tlsFixtures, "agent10-cert.pem"), "utf8"); +// ec10 chains to ca5; agent1 chains to ca1 and is not trusted by ca5. +const clientCa = readFileSync(join(tlsFixtures, "ca5-cert.pem"), "utf8"); +const trustedClient = { + key: readFileSync(join(tlsFixtures, "ec10-key.pem"), "utf8"), + cert: readFileSync(join(tlsFixtures, "ec10-cert.pem"), "utf8"), +}; +const untrustedClient = { + key: readFileSync(join(tlsFixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(tlsFixtures, "agent1-cert.pem"), "utf8"), +}; + +type ClientOptions = { key?: string; cert?: string; session?: Buffer }; +function request(port: number, servername: string, clientTls: ClientOptions = {}) { + const { promise, resolve } = Promise.withResolvers<{ status: string; session: Buffer | undefined }>(); + const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...clientTls }); + let received = ""; + let session: Buffer | undefined; + socket.on("secureConnect", () => { + socket.write(`GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: close\r\n\r\n`); + }); + socket.on("session", buf => (session ??= buf)); + socket.on("data", chunk => (received += chunk.toString())); + // A rejected client sees either a clean close or a reset; both mean no response. + socket.on("error", () => {}); + socket.on("close", () => + resolve({ status: received.split("\r\n")[0] || "connection closed without a response", session }), + ); + return promise; +} - type ClientOptions = { key?: string; cert?: string; session?: Buffer }; - function request(port: number, servername: string, clientTls: ClientOptions = {}) { - const { promise, resolve } = Promise.withResolvers<{ status: string; session: Buffer | undefined }>(); - const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...clientTls }); - let received = ""; - let session: Buffer | undefined; - socket.on("secureConnect", () => { - socket.write(`GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: close\r\n\r\n`); +describe("Bun.serve tls material given both inline and as a *File path", () => { + // agent1 chains to ca1 (given inline), ec10 chains to ca5 (given as caFile). + const ca1 = readFileSync(join(tlsFixtures, "ca1-cert.pem"), "utf8"); + const ca5File = join(tlsFixtures, "ca5-cert.pem"); + + for (const order of ["ca first", "caFile first"]) { + test(`ca and caFile are one trust list (${order})`, async () => { + const material = order === "ca first" ? { ca: ca1, caFile: ca5File } : { caFile: ca5File, ca: ca1 }; + using server = Bun.serve({ + port: 0, + tls: { key: serverKey, cert: serverCert, ...material, requestCert: true, rejectUnauthorized: true }, + fetch: () => new Response("ok"), + }); + const { status: viaInlineCa } = await request(server.port, "localhost", untrustedClient); + const { status: viaCaFile } = await request(server.port, "localhost", trustedClient); + const { status: noCert } = await request(server.port, "localhost"); + expect({ viaInlineCa, viaCaFile, noCert }).toEqual({ + viaInlineCa: "HTTP/1.1 200 OK", + viaCaFile: "HTTP/1.1 200 OK", + noCert: "connection closed without a response", + }); }); - socket.on("session", buf => (session ??= buf)); - socket.on("data", chunk => (received += chunk.toString())); - // A rejected client sees either a clean close or a reset; both mean no response. - socket.on("error", () => {}); - socket.on("close", () => - resolve({ status: received.split("\r\n")[0] || "connection closed without a response", session }), - ); - return promise; } + test("an invalid inline ca is still rejected when caFile is set", () => { + const tls = { + key: serverKey, + cert: serverCert, + ca: "-----BEGIN CERTIFICATE-----\nZ2FyYmFnZQ==\n-----END CERTIFICATE-----\n", + caFile: ca5File, + }; + expect(() => Bun.serve({ port: 0, tls, fetch: () => new Response("ok") })).toThrow( + "Failed to create HTTP server", + ); + // Bun.listen names the SSL context error. + expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls, socket: { data() {} } })).toThrow( + /^Invalid CA$/, + ); + }); + + test("cert + certFile and key + keyFile throw instead of serving one of them", () => { + const serve = (extra: object) => + Bun.serve({ port: 0, tls: { key: serverKey, cert: serverCert, ...extra }, fetch: () => new Response("ok") }); + expect(() => serve({ certFile: join(tlsFixtures, "agent10-cert.pem") })).toThrow( + "TLSOptions.cert and TLSOptions.certFile cannot both be set. Pass the file as cert: Bun.file(path) instead.", + ); + expect(() => serve({ keyFile: join(tlsFixtures, "agent10-key.pem") })).toThrow( + "TLSOptions.key and TLSOptions.keyFile cannot both be set. Pass the file as key: Bun.file(path) instead.", + ); + }); +}); + +describe("Bun.serve per-serverName client certificate policy", () => { test("requestCert/rejectUnauthorized on a non-default serverName entry are enforced for that name only", async () => { using server = Bun.serve({ port: 0, From 325a5401b803070466f6e44bc0ccd8bb30905d52 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:21:03 +0000 Subject: [PATCH 2/7] [autofix.ci] apply automated fixes --- test/js/bun/http/bun-serve-ssl.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index 30dd55aa6358..cd1b37cf2ccd 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -217,13 +217,9 @@ describe("Bun.serve tls material given both inline and as a *File path", () => { ca: "-----BEGIN CERTIFICATE-----\nZ2FyYmFnZQ==\n-----END CERTIFICATE-----\n", caFile: ca5File, }; - expect(() => Bun.serve({ port: 0, tls, fetch: () => new Response("ok") })).toThrow( - "Failed to create HTTP server", - ); + expect(() => Bun.serve({ port: 0, tls, fetch: () => new Response("ok") })).toThrow("Failed to create HTTP server"); // Bun.listen names the SSL context error. - expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls, socket: { data() {} } })).toThrow( - /^Invalid CA$/, - ); + expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls, socket: { data() {} } })).toThrow(/^Invalid CA$/); }); test("cert + certFile and key + keyFile throw instead of serving one of them", () => { From d979c55221a2821d049b4798319c278232733f4d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:55:57 +0000 Subject: [PATCH 3/7] SSLConfig: shorten the key/cert conflict comment; use test.each for the ca/caFile order matrix --- src/runtime/socket/SSLConfig.rs | 4 +--- test/js/bun/http/bun-serve-ssl.test.ts | 32 ++++++++++++-------------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 46c09e7f3125..7143bf552bc9 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -219,9 +219,7 @@ impl SSLConfigFromJs for SSLConfig { || result.session_timeout != 0 || result.allow_partial_trust_chain; - // The SSL_CTX holds one identity per key type, so a file and an inline - // value for the same slot cannot both be used; refuse instead of - // serving whichever one the builder happens to load. + // One identity per key type: a file and an inline value for the same slot cannot both be used. if let Some(key_file) = generated.key_file.as_ref() { if result.key.is_some() { return Err(global.throw_invalid_arguments(format_args!( diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index cd1b37cf2ccd..ffc05aa8c3e5 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -191,24 +191,22 @@ describe("Bun.serve tls material given both inline and as a *File path", () => { const ca1 = readFileSync(join(tlsFixtures, "ca1-cert.pem"), "utf8"); const ca5File = join(tlsFixtures, "ca5-cert.pem"); - for (const order of ["ca first", "caFile first"]) { - test(`ca and caFile are one trust list (${order})`, async () => { - const material = order === "ca first" ? { ca: ca1, caFile: ca5File } : { caFile: ca5File, ca: ca1 }; - using server = Bun.serve({ - port: 0, - tls: { key: serverKey, cert: serverCert, ...material, requestCert: true, rejectUnauthorized: true }, - fetch: () => new Response("ok"), - }); - const { status: viaInlineCa } = await request(server.port, "localhost", untrustedClient); - const { status: viaCaFile } = await request(server.port, "localhost", trustedClient); - const { status: noCert } = await request(server.port, "localhost"); - expect({ viaInlineCa, viaCaFile, noCert }).toEqual({ - viaInlineCa: "HTTP/1.1 200 OK", - viaCaFile: "HTTP/1.1 200 OK", - noCert: "connection closed without a response", - }); + test.each(["ca first", "caFile first"])("ca and caFile are one trust list (%s)", async order => { + const material = order === "ca first" ? { ca: ca1, caFile: ca5File } : { caFile: ca5File, ca: ca1 }; + using server = Bun.serve({ + port: 0, + tls: { key: serverKey, cert: serverCert, ...material, requestCert: true, rejectUnauthorized: true }, + fetch: () => new Response("ok"), }); - } + const { status: viaInlineCa } = await request(server.port, "localhost", untrustedClient); + const { status: viaCaFile } = await request(server.port, "localhost", trustedClient); + const { status: noCert } = await request(server.port, "localhost"); + expect({ viaInlineCa, viaCaFile, noCert }).toEqual({ + viaInlineCa: "HTTP/1.1 200 OK", + viaCaFile: "HTTP/1.1 200 OK", + noCert: "connection closed without a response", + }); + }); test("an invalid inline ca is still rejected when caFile is set", () => { const tls = { From 863efdbbc1e0f5b67b0d1d8024851a3b51e72119 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:12:22 +0000 Subject: [PATCH 4/7] install: --ca/--cafile replace the config CA settings as a unit; drop the C merge Review: the usockets change added a second path-based CA loader to the exact hunk that #40984 removes ("hand usockets the bytes"), and the cert+certFile/key+keyFile throw is a separate API decision reachable from node:tls option bags. Both are reverted here. What remains is the bun install precedence bug: a bunfig or .npmrc cafile shadowed --ca because each layer overlaid ca and cafile per field and the TLS layer uses only cafile when both are set. Treat the pair as one setting at every layer: --ca/--cafile replace both from config, a local bunfig replaces both from the global one, a project .npmrc replaces both from the user one, and bunfig replaces both from .npmrc. Within one layer cafile still wins, which is now documented. --- docs/pm/npmrc.mdx | 2 + docs/runtime/bunfig.mdx | 2 + packages/bun-usockets/src/crypto/openssl.c | 93 +++++++-------- src/bunfig/bunfig.rs | 13 ++- src/ini/lib.rs | 13 ++- src/install/PackageManager.rs | 9 +- .../PackageManager/PackageManagerOptions.rs | 7 +- src/runtime/socket/SSLConfig.rs | 11 -- test/cli/install/bun-install-registry.test.ts | 6 +- test/js/bun/http/bun-serve-ssl.test.ts | 106 +++++------------- 10 files changed, 108 insertions(+), 154 deletions(-) diff --git a/docs/pm/npmrc.mdx b/docs/pm/npmrc.mdx index 6e734c9f32bc..9170c13a786d 100644 --- a/docs/pm/npmrc.mdx +++ b/docs/pm/npmrc.mdx @@ -188,6 +188,8 @@ ca[]="-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" cafile=/path/to/ca-bundle.crt ``` +If both are set, only `cafile` is used. A `ca` or `cafile` in `bunfig.toml`, or `--ca` / `--cafile` on the command line, replaces both. + ### `omit` and `include`: Control dependency types Control which dependency types Bun installs: diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index e2d3e8d589a7..c6f7d70c5940 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -618,6 +618,8 @@ ca = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" cafile = "path/to/cafile" ``` +Set one of the two. If both are set, only `cafile` is used. `--ca` or `--cafile` on the command line replaces both. + ### `install.cache` To configure the cache behavior: diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 2b8319c4f7cf..cb5d8706a08a 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -992,11 +992,16 @@ static X509_STORE *us_ssl_ctx_get_own_cert_store(SSL_CTX *ctx) { return store; } -/* Every CERTIFICATE block readable from `in` as DER into `certs`. User-supplied certificates are validated with a full - * parse here so a bad one fails the context rather than a later handshake; the parsed object is dropped and the store - * keeps the DER lazily. Returns the number appended, or -1 when the first certificate block does not decode or parse. - * As with a PEM_read_bio_X509 loop, blocks after one that fails are ignored. */ -static int us_collect_ca_bio(SSL_CTX *ctx, BIO *in, STACK_OF(CRYPTO_BUFFER) *certs) { +/* The `ca` option: every CERTIFICATE block of `content` as DER into `certs`. User-supplied certificates are validated + * with a full parse here so a bad one fails the context rather than a later handshake; the parsed object is dropped and + * the store keeps the DER lazily. Returns the number appended, or -1 for content that is not PEM or whose first + * certificate block does not decode or parse. As with a PEM_read_bio_X509 loop, a PEM document with no certificates + * (Node's tests pass a private key) counts as zero, and blocks after one that fails are ignored. */ +static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_BUFFER) *certs) { + if (content == NULL) return 0; + ERR_clear_error(); + BIO *in = BIO_new_mem_buf(content, strlen(content)); + if (in == NULL) return -1; int count = 0; for (;;) { uint8_t *der = NULL; @@ -1022,18 +1027,6 @@ static int us_collect_ca_bio(SSL_CTX *ctx, BIO *in, STACK_OF(CRYPTO_BUFFER) *cer } count++; } - return count; -} - -/* The `ca` option: the CERTIFICATE blocks of `content` into `certs`. Returns the number appended, or -1 for content that - * is not PEM or whose first certificate block does not decode or parse. A PEM document with no certificates (Node's - * tests pass a private key) counts as zero. */ -static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_BUFFER) *certs) { - if (content == NULL) return 0; - ERR_clear_error(); - BIO *in = BIO_new_mem_buf(content, strlen(content)); - if (in == NULL) return -1; - int count = us_collect_ca_bio(ctx, in, certs); BIO_free(in); if (count == 0) { unsigned long pem_err = ERR_peek_last_error(); @@ -1046,28 +1039,6 @@ static int us_collect_ca_pem(SSL_CTX *ctx, const char *content, STACK_OF(CRYPTO_ return count; } -/* The `caFile` option: the file's CERTIFICATE blocks into `certs`, as us_collect_ca_pem does for an inline `ca` entry, - * except that a file holding no certificate is an error, as it was for SSL_load_client_CA_file. Returns the number - * appended, or -1 with *err set: LOAD_CA_FILE when the file cannot be opened, INVALID_CA_FILE for its contents. */ -static int us_collect_ca_file(SSL_CTX *ctx, const char *path, STACK_OF(CRYPTO_BUFFER) *certs, - enum create_bun_socket_error_t *err) { - ERR_clear_error(); - BIO *in = BIO_new_file(path, "rb"); - if (in == NULL) { - ERR_clear_error(); - *err = CREATE_BUN_SOCKET_ERROR_LOAD_CA_FILE; - return -1; - } - int count = us_collect_ca_bio(ctx, in, certs); - BIO_free(in); - ERR_clear_error(); - if (count <= 0) { - *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; - return -1; - } - return count; -} - static int us_crypto_buffer_cmp(const CRYPTO_BUFFER *const *a, const CRYPTO_BUFFER *const *b) { size_t al = CRYPTO_BUFFER_len(*a), bl = CRYPTO_BUFFER_len(*b); if (al != bl) return al < bl ? -1 : 1; @@ -1377,34 +1348,46 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, * everywhere downstream — no special "owner" path. */ ssl_ctx_drop_passphrase(ssl_context); - if (options.ca_file_name || (options.ca && options.ca_count > 0)) { + if (options.ca_file_name) { /* An explicit CA replaces the default trust store (Node.js semantics): - * chains must validate exclusively against the supplied CAs, so they go - * into the SSL_CTX's own, initially-empty X509_STORE from SSL_CTX_new() — - * otherwise a server doing mTLS with `ca: [internalCA]` would also accept - * any client certificate that chains to a public root. `caFile` and the - * inline `ca` entries are one trust list: both are collected here and - * installed together, rather than the file shadowing the inline CAs. */ + * chains must validate exclusively against the supplied CAs. The SSL_CTX + * already owns a fresh, empty X509_STORE from SSL_CTX_new(), so + * SSL_CTX_load_verify_locations below populates only the user's CAs. */ + STACK_OF(X509_NAME) *ca_list = SSL_load_client_CA_file(options.ca_file_name); + if (ca_list == NULL) { + *err = CREATE_BUN_SOCKET_ERROR_LOAD_CA_FILE; + ssl_ctx_build_fail(ssl_context); + return NULL; + } + SSL_CTX_set_client_CA_list(ssl_context, ca_list); us_ex_idx_ensure(); SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); + if (SSL_CTX_load_verify_locations(ssl_context, options.ca_file_name, NULL) != 1) { + *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; + ssl_ctx_build_fail(ssl_context); + return NULL; + } + SSL_CTX_set_verify(ssl_context, + options.reject_unauthorized ? (SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT) + : SSL_VERIFY_PEER, + us_verify_callback); + + } else if (options.ca && options.ca_count > 0) { + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); + /* As above: user CAs only, into the SSL_CTX's own initially-empty store — + * otherwise a server doing mTLS with `ca: [internalCA]` would also accept + * any client certificate that chains to a public root. */ X509_STORE *cert_store = SSL_CTX_get_cert_store(ssl_context); STACK_OF(CRYPTO_BUFFER) *ca_certs = sk_CRYPTO_BUFFER_new_null(); - enum create_bun_socket_error_t ca_err = options.ca && options.ca_count > 0 ? CREATE_BUN_SOCKET_ERROR_INVALID_CA - : CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE; - if (ca_certs != NULL && options.ca_file_name && - us_collect_ca_file(ssl_context, options.ca_file_name, ca_certs, &ca_err) < 0) { - sk_CRYPTO_BUFFER_pop_free(ca_certs, CRYPTO_BUFFER_free); - ca_certs = NULL; - } for (unsigned int i = 0; ca_certs != NULL && i < options.ca_count; i++) { if (us_collect_ca_pem(ssl_context, options.ca[i], ca_certs) < 0) { - ca_err = CREATE_BUN_SOCKET_ERROR_INVALID_CA; sk_CRYPTO_BUFFER_pop_free(ca_certs, CRYPTO_BUFFER_free); ca_certs = NULL; } } if (ca_certs == NULL || !us_install_ca_buffers(ssl_context, cert_store, ca_certs)) { - *err = ca_err; + *err = CREATE_BUN_SOCKET_ERROR_INVALID_CA; ssl_ctx_build_fail(ssl_context); return NULL; } diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 0d8afad488d1..e98f8ff9c555 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1272,7 +1272,16 @@ impl<'a> Parser<'a> { install: &mut api::BunInstall, install_obj: &Expr, ) -> crate::Result<()> { - if let Some(cafile) = install_obj.get(b"cafile") { + let cafile = install_obj.get(b"cafile"); + let ca = install_obj.get(b"ca"); + // `ca` and `cafile` are one setting (only `cafile` is used when both are set), + // so a local bunfig that sets either replaces both from the global bunfig. + if cafile.is_some() || ca.is_some() { + install.cafile = None; + install.ca = None; + } + + if let Some(cafile) = cafile { install.cafile = match cafile.as_string(self.bump) { Some(s) => Some(s.into()), None => { @@ -1282,7 +1291,7 @@ impl<'a> Parser<'a> { }; } - if let Some(ca) = install_obj.get(b"ca") { + if let Some(ca) = ca { match &ca.data { ExprData::EArray(arr) => { let items = arr.items.slice(); diff --git a/src/ini/lib.rs b/src/ini/lib.rs index a9de2636cb6b..9b3ae04d64bd 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1367,7 +1367,16 @@ mod draft { } } - if let Some(query) = out.as_property(b"ca") { + let ca = out.as_property(b"ca"); + let cafile = out.as_property(b"cafile"); + // `ca` and `cafile` are one setting (only `cafile` is used when both are set), + // so a project .npmrc that sets either replaces both from the user .npmrc. + if ca.is_some() || cafile.is_some() { + install.ca = None; + install.cafile = None; + } + + if let Some(query) = ca { if let Some(str_) = query.expr.as_utf8_string_literal() { install.ca = Some(bun_api::Ca::Str(Box::<[u8]>::from(str_))); } else if let ExprData::EArray(arr) = &query.expr.data { @@ -1381,7 +1390,7 @@ mod draft { } } - if let Some(query) = out.as_property(b"cafile") { + if let Some(query) = cafile { if let Some(cafile) = query.expr.as_string_cloned(bump)? { install.cafile = Some(Box::<[u8]>::from(cafile)); } diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index e2c97f58844d..12aa4100c926 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1431,6 +1431,13 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall } } + // `ca` and `cafile` are one setting: the TLS layer uses only `cafile` when both + // are set, so bunfig replaces both or neither rather than mixing with .npmrc. + if ca.is_some() || cafile.is_some() { + install.ca = ca; + install.cafile = cafile; + } + macro_rules! overlay { ($($field:ident),* $(,)?) => { $( if $field.is_some() { install.$field = $field; } )* @@ -1455,9 +1462,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall frozen_lockfile, exact, concurrent_scripts, - cafile, save_text_lockfile, - ca, ignore_scripts, link_workspace_packages, node_linker, diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index e89a22f8b2e0..ee001d450e8a 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -898,10 +898,11 @@ impl Options { } self.publish_config.tolerate_republish = cli.tolerate_republish; - if !cli.ca.is_empty() { + // `--ca` / `--cafile` replace the CA configuration from bunfig and .npmrc as a + // whole; the TLS layer uses only `cafile` when both are set, so a per-field + // overlay would let a config file's `cafile` shadow a `--ca` flag. + if !cli.ca.is_empty() || !cli.ca_file_name.is_empty() { self.ca = cli.ca.iter().map(|s| Box::<[u8]>::from(*s)).collect(); - } - if !cli.ca_file_name.is_empty() { self.ca_file_name = cli.ca_file_name; } diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 7143bf552bc9..3823142abe5f 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -219,22 +219,11 @@ impl SSLConfigFromJs for SSLConfig { || result.session_timeout != 0 || result.allow_partial_trust_chain; - // One identity per key type: a file and an inline value for the same slot cannot both be used. if let Some(key_file) = generated.key_file.as_ref() { - if result.key.is_some() { - return Err(global.throw_invalid_arguments(format_args!( - "TLSOptions.key and TLSOptions.keyFile cannot both be set. Pass the file as key: Bun.file(path) instead." - ))); - } result.key_file_name = handle_path(global, "keyFile", key_file)?; result.requires_custom_request_ctx = true; } if let Some(cert_file) = generated.cert_file.as_ref() { - if result.cert.is_some() { - return Err(global.throw_invalid_arguments(format_args!( - "TLSOptions.cert and TLSOptions.certFile cannot both be set. Pass the file as cert: Bun.file(path) instead." - ))); - } result.cert_file_name = handle_path(global, "certFile", cert_file)?; result.requires_custom_request_ctx = true; } diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index ee2513763180..3dbe966e6a1d 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -232,10 +232,10 @@ describe("certificate authority", () => { expect(err).not.toContain("error:"); expect(await exited).toBe(0); }); - test("--ca is trusted in addition to a cafile from bunfig", async () => { + test("--ca replaces a cafile from bunfig", async () => { // The registry's certificate is only in --ca; the bunfig cafile holds an - // unrelated CA. Both are one trust list, so the CLI flag is not shadowed - // by the config file. + // unrelated CA. The CLI flag replaces the config file's CA settings as a + // whole instead of being shadowed by its cafile. using server = Bun.serve({ port: 0, fetch: mockRegistryFetch(), diff --git a/test/js/bun/http/bun-serve-ssl.test.ts b/test/js/bun/http/bun-serve-ssl.test.ts index ffc05aa8c3e5..9042e56d61df 100644 --- a/test/js/bun/http/bun-serve-ssl.test.ts +++ b/test/js/bun/http/bun-serve-ssl.test.ts @@ -153,86 +153,40 @@ describe("Bun.serve SSL validations", () => { } }); -const tlsFixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); -const serverKey = readFileSync(join(tlsFixtures, "agent10-key.pem"), "utf8"); -const serverCert = readFileSync(join(tlsFixtures, "agent10-cert.pem"), "utf8"); -// ec10 chains to ca5; agent1 chains to ca1 and is not trusted by ca5. -const clientCa = readFileSync(join(tlsFixtures, "ca5-cert.pem"), "utf8"); -const trustedClient = { - key: readFileSync(join(tlsFixtures, "ec10-key.pem"), "utf8"), - cert: readFileSync(join(tlsFixtures, "ec10-cert.pem"), "utf8"), -}; -const untrustedClient = { - key: readFileSync(join(tlsFixtures, "agent1-key.pem"), "utf8"), - cert: readFileSync(join(tlsFixtures, "agent1-cert.pem"), "utf8"), -}; - -type ClientOptions = { key?: string; cert?: string; session?: Buffer }; -function request(port: number, servername: string, clientTls: ClientOptions = {}) { - const { promise, resolve } = Promise.withResolvers<{ status: string; session: Buffer | undefined }>(); - const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...clientTls }); - let received = ""; - let session: Buffer | undefined; - socket.on("secureConnect", () => { - socket.write(`GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: close\r\n\r\n`); - }); - socket.on("session", buf => (session ??= buf)); - socket.on("data", chunk => (received += chunk.toString())); - // A rejected client sees either a clean close or a reset; both mean no response. - socket.on("error", () => {}); - socket.on("close", () => - resolve({ status: received.split("\r\n")[0] || "connection closed without a response", session }), - ); - return promise; -} - -describe("Bun.serve tls material given both inline and as a *File path", () => { - // agent1 chains to ca1 (given inline), ec10 chains to ca5 (given as caFile). - const ca1 = readFileSync(join(tlsFixtures, "ca1-cert.pem"), "utf8"); - const ca5File = join(tlsFixtures, "ca5-cert.pem"); +describe("Bun.serve per-serverName client certificate policy", () => { + const tlsFixtures = join(import.meta.dir, "..", "..", "node", "tls", "fixtures"); + const serverKey = readFileSync(join(tlsFixtures, "agent10-key.pem"), "utf8"); + const serverCert = readFileSync(join(tlsFixtures, "agent10-cert.pem"), "utf8"); + // ec10 chains to ca5; agent1 chains to ca1 and is not trusted by ca5. + const clientCa = readFileSync(join(tlsFixtures, "ca5-cert.pem"), "utf8"); + const trustedClient = { + key: readFileSync(join(tlsFixtures, "ec10-key.pem"), "utf8"), + cert: readFileSync(join(tlsFixtures, "ec10-cert.pem"), "utf8"), + }; + const untrustedClient = { + key: readFileSync(join(tlsFixtures, "agent1-key.pem"), "utf8"), + cert: readFileSync(join(tlsFixtures, "agent1-cert.pem"), "utf8"), + }; - test.each(["ca first", "caFile first"])("ca and caFile are one trust list (%s)", async order => { - const material = order === "ca first" ? { ca: ca1, caFile: ca5File } : { caFile: ca5File, ca: ca1 }; - using server = Bun.serve({ - port: 0, - tls: { key: serverKey, cert: serverCert, ...material, requestCert: true, rejectUnauthorized: true }, - fetch: () => new Response("ok"), + type ClientOptions = { key?: string; cert?: string; session?: Buffer }; + function request(port: number, servername: string, clientTls: ClientOptions = {}) { + const { promise, resolve } = Promise.withResolvers<{ status: string; session: Buffer | undefined }>(); + const socket = tls.connect({ host: "127.0.0.1", port, servername, rejectUnauthorized: false, ...clientTls }); + let received = ""; + let session: Buffer | undefined; + socket.on("secureConnect", () => { + socket.write(`GET / HTTP/1.1\r\nHost: ${servername}\r\nConnection: close\r\n\r\n`); }); - const { status: viaInlineCa } = await request(server.port, "localhost", untrustedClient); - const { status: viaCaFile } = await request(server.port, "localhost", trustedClient); - const { status: noCert } = await request(server.port, "localhost"); - expect({ viaInlineCa, viaCaFile, noCert }).toEqual({ - viaInlineCa: "HTTP/1.1 200 OK", - viaCaFile: "HTTP/1.1 200 OK", - noCert: "connection closed without a response", - }); - }); - - test("an invalid inline ca is still rejected when caFile is set", () => { - const tls = { - key: serverKey, - cert: serverCert, - ca: "-----BEGIN CERTIFICATE-----\nZ2FyYmFnZQ==\n-----END CERTIFICATE-----\n", - caFile: ca5File, - }; - expect(() => Bun.serve({ port: 0, tls, fetch: () => new Response("ok") })).toThrow("Failed to create HTTP server"); - // Bun.listen names the SSL context error. - expect(() => Bun.listen({ hostname: "127.0.0.1", port: 0, tls, socket: { data() {} } })).toThrow(/^Invalid CA$/); - }); - - test("cert + certFile and key + keyFile throw instead of serving one of them", () => { - const serve = (extra: object) => - Bun.serve({ port: 0, tls: { key: serverKey, cert: serverCert, ...extra }, fetch: () => new Response("ok") }); - expect(() => serve({ certFile: join(tlsFixtures, "agent10-cert.pem") })).toThrow( - "TLSOptions.cert and TLSOptions.certFile cannot both be set. Pass the file as cert: Bun.file(path) instead.", - ); - expect(() => serve({ keyFile: join(tlsFixtures, "agent10-key.pem") })).toThrow( - "TLSOptions.key and TLSOptions.keyFile cannot both be set. Pass the file as key: Bun.file(path) instead.", + socket.on("session", buf => (session ??= buf)); + socket.on("data", chunk => (received += chunk.toString())); + // A rejected client sees either a clean close or a reset; both mean no response. + socket.on("error", () => {}); + socket.on("close", () => + resolve({ status: received.split("\r\n")[0] || "connection closed without a response", session }), ); - }); -}); + return promise; + } -describe("Bun.serve per-serverName client certificate policy", () => { test("requestCert/rejectUnauthorized on a non-default serverName entry are enforced for that name only", async () => { using server = Bun.serve({ port: 0, From adfa87ac094d1b5184a6e658a9fb4715225ff45f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:14:38 +0000 Subject: [PATCH 5/7] install: one-line comments for the ca/cafile unit rule --- src/bunfig/bunfig.rs | 3 +-- src/ini/lib.rs | 3 +-- src/install/PackageManager.rs | 3 +-- src/install/PackageManager/PackageManagerOptions.rs | 4 +--- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index e98f8ff9c555..c936aa845b6c 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -1274,8 +1274,7 @@ impl<'a> Parser<'a> { ) -> crate::Result<()> { let cafile = install_obj.get(b"cafile"); let ca = install_obj.get(b"ca"); - // `ca` and `cafile` are one setting (only `cafile` is used when both are set), - // so a local bunfig that sets either replaces both from the global bunfig. + // `ca` + `cafile` are one setting: a file that sets either replaces both. if cafile.is_some() || ca.is_some() { install.cafile = None; install.ca = None; diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 9b3ae04d64bd..5ac4c4345621 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -1369,8 +1369,7 @@ mod draft { let ca = out.as_property(b"ca"); let cafile = out.as_property(b"cafile"); - // `ca` and `cafile` are one setting (only `cafile` is used when both are set), - // so a project .npmrc that sets either replaces both from the user .npmrc. + // `ca` + `cafile` are one setting: a file that sets either replaces both. if ca.is_some() || cafile.is_some() { install.ca = None; install.cafile = None; diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 12aa4100c926..b77353a96e8e 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -1431,8 +1431,7 @@ fn overlay_bunfig_install(install: &mut Api::BunInstall, bunfig: Api::BunInstall } } - // `ca` and `cafile` are one setting: the TLS layer uses only `cafile` when both - // are set, so bunfig replaces both or neither rather than mixing with .npmrc. + // `ca` + `cafile` are one setting: bunfig replaces both or neither. if ca.is_some() || cafile.is_some() { install.ca = ca; install.cafile = cafile; diff --git a/src/install/PackageManager/PackageManagerOptions.rs b/src/install/PackageManager/PackageManagerOptions.rs index ee001d450e8a..b0e591ee5f44 100644 --- a/src/install/PackageManager/PackageManagerOptions.rs +++ b/src/install/PackageManager/PackageManagerOptions.rs @@ -898,9 +898,7 @@ impl Options { } self.publish_config.tolerate_republish = cli.tolerate_republish; - // `--ca` / `--cafile` replace the CA configuration from bunfig and .npmrc as a - // whole; the TLS layer uses only `cafile` when both are set, so a per-field - // overlay would let a config file's `cafile` shadow a `--ca` flag. + // `--ca` / `--cafile` replace the config file's `ca` + `cafile` as a whole. if !cli.ca.is_empty() || !cli.ca_file_name.is_empty() { self.ca = cli.ca.iter().map(|s| Box::<[u8]>::from(*s)).collect(); self.ca_file_name = cli.ca_file_name; From a7229e47433e7e617bae05ce6ca0fd595b5a6553 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:36:05 +0000 Subject: [PATCH 6/7] install test: pin bunfig-over-npmrc and project-over-user npmrc ca/cafile precedence --- test/cli/install/bun-install-registry.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index 3dbe966e6a1d..b72e9e7d8dfd 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -282,6 +282,64 @@ describe("certificate authority", () => { expect(out).toContain("+ no-deps@"); expect(await exited).toBe(0); }); + // Each higher layer's `ca` must replace a lower layer's `cafile` (the lower + // file holds an unrelated CA, the registry's certificate is only in `ca`). + test.each(["bunfig ca over .npmrc cafile", "project .npmrc ca over user .npmrc cafile"])( + "%s", + async layers => { + using server = Bun.serve({ + port: 0, + fetch: mockRegistryFetch(), + ...tls, + }); + const homeDir = join(packageDir, "home_dir"); + const unrelatedCa = join(packageDir, "unrelated-ca.pem"); + const npmrcCa = `ca=${JSON.stringify(tls.cert)}\n`; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.1.1", + dependencies: { + "no-deps": `https://localhost:${server.port}/no-deps-1.0.0.tgz`, + }, + }), + ), + write(unrelatedCa, file(join(import.meta.dir, "..", "..", "js", "node", "tls", "fixtures", "ca1-cert.pem"))), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: `https://localhost:${server.port}/`, + ...(layers.startsWith("bunfig") ? { ca: tls.cert } : {}), + }, + }), + ), + layers.startsWith("bunfig") + ? write(join(packageDir, ".npmrc"), `cafile=${unrelatedCa}\n`) + : Promise.all([ + write(join(homeDir, ".npmrc"), `cafile=${unrelatedCa}\n`), + write(join(packageDir, ".npmrc"), npmrcCa), + ]), + ]); + + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stderr: "pipe", + stdout: "pipe", + env: { ...env, XDG_CONFIG_HOME: homeDir }, + }); + const out = await stdout.text(); + const err = await stderr.text(); + expect(err).not.toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(err).not.toContain("error:"); + expect(out).toContain("+ no-deps@"); + expect(await exited).toBe(0); + }, + ); test(`non-existent --cafile`, async () => { await write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0", "dependencies": { "no-deps": "1.1.1" } })); From ac2671be3840b485ff20feb95bdf1bd5ff3a3a51 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:36:38 +0000 Subject: [PATCH 7/7] prettier --- test/cli/install/bun-install-registry.test.ts | 107 +++++++++--------- 1 file changed, 52 insertions(+), 55 deletions(-) diff --git a/test/cli/install/bun-install-registry.test.ts b/test/cli/install/bun-install-registry.test.ts index b72e9e7d8dfd..50682b2006b4 100644 --- a/test/cli/install/bun-install-registry.test.ts +++ b/test/cli/install/bun-install-registry.test.ts @@ -284,62 +284,59 @@ describe("certificate authority", () => { }); // Each higher layer's `ca` must replace a lower layer's `cafile` (the lower // file holds an unrelated CA, the registry's certificate is only in `ca`). - test.each(["bunfig ca over .npmrc cafile", "project .npmrc ca over user .npmrc cafile"])( - "%s", - async layers => { - using server = Bun.serve({ - port: 0, - fetch: mockRegistryFetch(), - ...tls, - }); - const homeDir = join(packageDir, "home_dir"); - const unrelatedCa = join(packageDir, "unrelated-ca.pem"); - const npmrcCa = `ca=${JSON.stringify(tls.cert)}\n`; - await Promise.all([ - write( - packageJson, - JSON.stringify({ - name: "foo", - version: "1.1.1", - dependencies: { - "no-deps": `https://localhost:${server.port}/no-deps-1.0.0.tgz`, - }, - }), - ), - write(unrelatedCa, file(join(import.meta.dir, "..", "..", "js", "node", "tls", "fixtures", "ca1-cert.pem"))), - write( - join(packageDir, "bunfig.toml"), - Bun.TOML.stringify({ - install: { - cache: false, - registry: `https://localhost:${server.port}/`, - ...(layers.startsWith("bunfig") ? { ca: tls.cert } : {}), - }, - }), - ), - layers.startsWith("bunfig") - ? write(join(packageDir, ".npmrc"), `cafile=${unrelatedCa}\n`) - : Promise.all([ - write(join(homeDir, ".npmrc"), `cafile=${unrelatedCa}\n`), - write(join(packageDir, ".npmrc"), npmrcCa), - ]), - ]); + test.each(["bunfig ca over .npmrc cafile", "project .npmrc ca over user .npmrc cafile"])("%s", async layers => { + using server = Bun.serve({ + port: 0, + fetch: mockRegistryFetch(), + ...tls, + }); + const homeDir = join(packageDir, "home_dir"); + const unrelatedCa = join(packageDir, "unrelated-ca.pem"); + const npmrcCa = `ca=${JSON.stringify(tls.cert)}\n`; + await Promise.all([ + write( + packageJson, + JSON.stringify({ + name: "foo", + version: "1.1.1", + dependencies: { + "no-deps": `https://localhost:${server.port}/no-deps-1.0.0.tgz`, + }, + }), + ), + write(unrelatedCa, file(join(import.meta.dir, "..", "..", "js", "node", "tls", "fixtures", "ca1-cert.pem"))), + write( + join(packageDir, "bunfig.toml"), + Bun.TOML.stringify({ + install: { + cache: false, + registry: `https://localhost:${server.port}/`, + ...(layers.startsWith("bunfig") ? { ca: tls.cert } : {}), + }, + }), + ), + layers.startsWith("bunfig") + ? write(join(packageDir, ".npmrc"), `cafile=${unrelatedCa}\n`) + : Promise.all([ + write(join(homeDir, ".npmrc"), `cafile=${unrelatedCa}\n`), + write(join(packageDir, ".npmrc"), npmrcCa), + ]), + ]); - const { stdout, stderr, exited } = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - stderr: "pipe", - stdout: "pipe", - env: { ...env, XDG_CONFIG_HOME: homeDir }, - }); - const out = await stdout.text(); - const err = await stderr.text(); - expect(err).not.toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); - expect(err).not.toContain("error:"); - expect(out).toContain("+ no-deps@"); - expect(await exited).toBe(0); - }, - ); + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + stderr: "pipe", + stdout: "pipe", + env: { ...env, XDG_CONFIG_HOME: homeDir }, + }); + const out = await stdout.text(); + const err = await stderr.text(); + expect(err).not.toContain("DEPTH_ZERO_SELF_SIGNED_CERT"); + expect(err).not.toContain("error:"); + expect(out).toContain("+ no-deps@"); + expect(await exited).toBe(0); + }); test(`non-existent --cafile`, async () => { await write(packageJson, JSON.stringify({ name: "foo", version: "1.0.0", "dependencies": { "no-deps": "1.1.1" } }));