From 2ca2884d0a69ff734eb08c89ed38693a8d0abf06 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:02:12 +0000 Subject: [PATCH 1/8] node:tls: honor the crl option when building a SecureContext The crl option on tls.createServer / tls.connect / tls.createSecureContext was silently dropped before reaching BoringSSL, so a revoked client or server certificate was treated as authorized. Plumb the option through SSLConfig into us_bun_socket_context_options_t and load each CRL into the context's X509_STORE with X509_V_FLAG_CRL_CHECK|CRL_CHECK_ALL, matching Node's SecureContext::AddCRL. An unparseable CRL now throws ERR_CRYPTO_OPERATION_FAILED at context creation instead of being ignored. --- packages/bun-usockets/src/crypto/openssl.c | 52 +++++++++ packages/bun-usockets/src/libusockets.h | 3 + packages/bun-uws/src/App.h | 2 + src/http/ssl_config.rs | 10 ++ src/js/node/tls.ts | 10 ++ src/jsc/generated.rs | 5 +- src/runtime/socket/SSLConfig.bindv2.ts | 1 + src/runtime/socket/SSLConfig.rs | 4 +- src/runtime/socket/uws_jsc.rs | 6 ++ src/sql_jsc/jsc.rs | 6 ++ src/uws_sys/SocketContext.rs | 6 ++ src/uws_sys/lib.rs | 2 + test/js/node/tls/node-tls-server.test.ts | 120 +++++++++++++++++++++ 13 files changed, 225 insertions(+), 2 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index c8528b0016e2..155ec2e77915 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -765,6 +765,32 @@ static int us_ssl_ctx_use_privatekey_content(SSL_CTX *ctx, const char *content, return ret; } +/* node:tls `crl`: parse each X509 CRL block in `content` into `store` and + * enable CRL checking on it (Node's SecureContext::AddCRL). Returns the number + * of CRLs added, 0 when no CRL could be parsed. */ +static int add_crl_to_ctx_store(const char *content, X509_STORE *store) { + int count = 0; + X509_CRL *crl = NULL; + ERR_clear_error(); + if (content == NULL) return 0; + BIO *in = BIO_new_mem_buf(content, strlen(content)); + if (in == NULL) { + OPENSSL_PUT_ERROR(SSL, ERR_R_BUF_LIB); + return 0; + } + while ((crl = PEM_read_bio_X509_CRL(in, NULL, NULL, NULL))) { + X509_STORE_add_crl(store, crl); + X509_CRL_free(crl); + count++; + } + BIO_free(in); + if (count > 0) { + X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL); + ERR_clear_error(); + } + return count; +} + static int add_ca_cert_to_ctx_store(SSL_CTX *ctx, const char *content, X509_STORE *store) { X509 *x = NULL; ERR_clear_error(); @@ -1030,6 +1056,32 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, us_verify_callback); } + if (options.crl && options.crl_count > 0) { + X509_STORE *store = SSL_CTX_get_cert_store(ssl_context); + /* Clone-on-write: a CRL must not be attached to the process-wide default + * root store (every other context would see it and start failing with + * UNABLE_TO_GET_CRL). Same check Node's SecureContext::AddCRL performs. */ + X509_STORE *shared = us_get_shared_default_ca_store(); + int store_is_shared = store && store == shared; + X509_STORE_free(shared); + if (store_is_shared) { + X509_STORE *own = us_get_default_ca_store(); + if (!own) { + ssl_ctx_build_fail(ssl_context); + return NULL; + } + SSL_CTX_set_cert_store(ssl_context, own); + store = own; + } + for (unsigned int i = 0; i < options.crl_count; i++) { + if (add_crl_to_ctx_store(options.crl[i], store) == 0) { + *err = CREATE_BUN_SOCKET_ERROR_INVALID_CRL; + ssl_ctx_build_fail(ssl_context); + return NULL; + } + } + } + if (options.dh_params_file_name) { DH *dh_2048 = NULL; FILE *paramfile = fopen(options.dh_params_file_name, "r"); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index c1ccae19acfe..110f2bc18c42 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -425,6 +425,8 @@ struct us_bun_socket_context_options_t { unsigned int cert_count; const char * const *ca; unsigned int ca_count; + const char * const *crl; + unsigned int crl_count; unsigned int secure_options; // Minimum/maximum TLS protocol version (TLS1_VERSION..TLS1_3_VERSION); 0 = unset/default. int ssl_min_version; @@ -441,6 +443,7 @@ enum create_bun_socket_error_t { CREATE_BUN_SOCKET_ERROR_INVALID_CA_FILE, CREATE_BUN_SOCKET_ERROR_INVALID_CA, CREATE_BUN_SOCKET_ERROR_INVALID_CIPHERS, + CREATE_BUN_SOCKET_ERROR_INVALID_CRL, }; /* Build an SSL_CTX from options. Returns the BoringSSL SSL_CTX*; caller owns diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index cccd7263ccd9..e3895a74f03e 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -73,6 +73,8 @@ namespace uWS { unsigned int cert_count = 0; const char **ca = nullptr; unsigned int ca_count = 0; + const char **crl = nullptr; + unsigned int crl_count = 0; unsigned int secure_options = 0; int ssl_min_version = 0; int ssl_max_version = 0; diff --git a/src/http/ssl_config.rs b/src/http/ssl_config.rs index 002b8a1a293c..56133c93c8f2 100644 --- a/src/http/ssl_config.rs +++ b/src/http/ssl_config.rs @@ -32,6 +32,7 @@ pub struct SSLConfig { pub key: CStrSlice, pub cert: CStrSlice, pub ca: CStrSlice, + pub crl: CStrSlice, pub secure_options: u32, /// Minimum/maximum TLS protocol version (TLS1_VERSION..TLS1_3_VERSION); 0 = unset/default. @@ -109,6 +110,7 @@ impl SSLConfig { key: None, cert: None, ca: None, + crl: None, secure_options: 0, ssl_min_version: 0, ssl_max_version: 0, @@ -203,6 +205,10 @@ impl SSLConfig { ctx_opts.ca = ca.as_ptr(); ctx_opts.ca_count = ca.len() as u32; } + if let Some(crl) = &self.crl { + ctx_opts.crl = crl.as_ptr(); + ctx_opts.crl_count = crl.len() as u32; + } if !self.ssl_ciphers.is_null() { ctx_opts.ssl_ciphers = self.ssl_ciphers; @@ -275,6 +281,7 @@ impl SSLConfig { eq_slice!(key); eq_slice!(cert); eq_slice!(ca); + eq_slice!(crl); if self.secure_options != other.secure_options { return false; } @@ -347,6 +354,7 @@ impl SSLConfig { hash_slice!(key); hash_slice!(cert); hash_slice!(ca); + hash_slice!(crl); hasher.update(&self.secure_options.to_ne_bytes()); hasher.update(&self.ssl_min_version.to_ne_bytes()); hasher.update(&self.ssl_max_version.to_ne_bytes()); @@ -386,6 +394,7 @@ impl SSLConfig { free_strings(&mut self.key); free_strings(&mut self.cert); free_strings(&mut self.ca); + free_strings(&mut self.crl); free_string(&mut self.ssl_ciphers); free_string(&mut self.protos); } @@ -438,6 +447,7 @@ impl Clone for SSLConfig { key: clone_strings(&self.key), cert: clone_strings(&self.cert), ca: clone_strings(&self.ca), + crl: clone_strings(&self.crl), secure_options: self.secure_options, ssl_min_version: self.ssl_min_version, ssl_max_version: self.ssl_max_version, diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 65b038c0aabc..47b99ad4aabd 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -785,6 +785,8 @@ var InternalSecureContext = class SecureContext { if (key) throwOnInvalidTLSArray("options.key", key); const ca = options.ca; if (ca) throwOnInvalidTLSArray("options.ca", ca); + const crl = options.crl; + if (crl) throwOnInvalidTLSArray("options.crl", crl); if (options.servername != null && typeof options.servername !== "string") throw new TypeError("servername argument must be an string"); if (options.secureOptions != null && typeof options.secureOptions !== "number") @@ -1245,6 +1247,7 @@ function Server(options, secureConnectionListener): void { this.key = undefined; this.cert = undefined; this.ca = undefined; + this.crl = undefined; this.passphrase = undefined; this.secureOptions = undefined; this._rejectUnauthorized = rejectUnauthorizedDefault(); @@ -1361,6 +1364,12 @@ function Server(options, secureConnectionListener): void { } this.ca = ca; + let crl = options.crl; + if (crl) { + throwOnInvalidTLSArray("options.crl", crl); + } + this.crl = crl; + let passphrase = options.passphrase; if (passphrase && typeof passphrase !== "string") { throw $ERR_INVALID_ARG_TYPE("options.passphrase", "string", passphrase); @@ -1438,6 +1447,7 @@ function Server(options, secureConnectionListener): void { key: this.key, cert: this.cert, ca: this.ca, + crl: this.crl, passphrase: this.passphrase, secureOptions: this.secureOptions, rejectUnauthorized: this._rejectUnauthorized, diff --git a/src/jsc/generated.rs b/src/jsc/generated.rs index f23a92f17812..170683bd4e61 100644 --- a/src/jsc/generated.rs +++ b/src/jsc/generated.rs @@ -308,6 +308,7 @@ pub struct SSLConfig { pub ca: SSLConfigFile, pub cert: SSLConfigFile, pub key: SSLConfigFile, + pub crl: SSLConfigFile, pub key_file: GenOpt, pub cert_file: GenOpt, pub ca_file: GenOpt, @@ -405,7 +406,7 @@ impl Drop for SSLConfig { release_gen_opt_string(&self.passphrase); release_gen_opt_string(&self.dh_params_file); release_gen_opt_string(&self.server_name); - // `ca` / `cert` / `key`: `SSLConfigFile` — released by its own `Drop`. + // `ca` / `cert` / `key` / `crl`: `SSLConfigFile` — released by its own `Drop`. release_gen_opt_string(&self.key_file); release_gen_opt_string(&self.cert_file); release_gen_opt_string(&self.ca_file); @@ -550,6 +551,7 @@ struct ExternSSLConfig { ca: ExternSSLConfigFile, cert: ExternSSLConfigFile, key: ExternSSLConfigFile, + crl: ExternSSLConfigFile, secure_options: u32, ssl_min_version: i32, ssl_max_version: i32, @@ -584,6 +586,7 @@ impl SSLConfig { ca: SSLConfigFile::convert_from_extern(ext.ca), cert: SSLConfigFile::convert_from_extern(ext.cert), key: SSLConfigFile::convert_from_extern(ext.key), + crl: SSLConfigFile::convert_from_extern(ext.crl), secure_options: ext.secure_options, ssl_min_version: ext.ssl_min_version, ssl_max_version: ext.ssl_max_version, diff --git a/src/runtime/socket/SSLConfig.bindv2.ts b/src/runtime/socket/SSLConfig.bindv2.ts index 2309bf9a616f..854a4207c08b 100644 --- a/src/runtime/socket/SSLConfig.bindv2.ts +++ b/src/runtime/socket/SSLConfig.bindv2.ts @@ -54,6 +54,7 @@ export const SSLConfig = b.dictionary( ca: SSLConfigFile, cert: SSLConfigFile, key: SSLConfigFile, + crl: SSLConfigFile, secureOptions: { type: b.u32, default: 0, diff --git a/src/runtime/socket/SSLConfig.rs b/src/runtime/socket/SSLConfig.rs index 919a47068e10..5986b300356f 100644 --- a/src/runtime/socket/SSLConfig.rs +++ b/src/runtime/socket/SSLConfig.rs @@ -189,10 +189,12 @@ impl SSLConfigFromJs for SSLConfig { result.ca = handle_file_for_field(global, "ca", &generated.ca)?; result.cert = handle_file_for_field(global, "cert", &generated.cert)?; result.key = handle_file_for_field(global, "key", &generated.key)?; + result.crl = handle_file_for_field(global, "crl", &generated.crl)?; result.requires_custom_request_ctx = result.requires_custom_request_ctx || result.ca.is_some() || result.cert.is_some() - || result.key.is_some(); + || result.key.is_some() + || result.crl.is_some(); if let Some(key_file) = generated.key_file.get() { result.key_file_name = handle_path(global, "keyFile", &key_file)?; diff --git a/src/runtime/socket/uws_jsc.rs b/src/runtime/socket/uws_jsc.rs index c67dea4333b2..49e8ad34525d 100644 --- a/src/runtime/socket/uws_jsc.rs +++ b/src/runtime/socket/uws_jsc.rs @@ -71,6 +71,12 @@ pub fn create_bun_socket_error_to_js( format_args!("Invalid ciphers"), ) .to_js(), + create_bun_socket_error_t::invalid_crl => global_object + .err( + bun_jsc::ErrorCode::ERR_CRYPTO_OPERATION_FAILED, + format_args!("Failed to parse CRL"), + ) + .to_js(), } } diff --git a/src/sql_jsc/jsc.rs b/src/sql_jsc/jsc.rs index 86f1698eefd1..b7a1938c0d1c 100644 --- a/src/sql_jsc/jsc.rs +++ b/src/sql_jsc/jsc.rs @@ -142,6 +142,12 @@ pub(crate) fn create_bun_socket_error_to_js( E::invalid_ciphers => global .err(ErrorCode::BORINGSSL, format_args!("Invalid ciphers")) .to_js(), + E::invalid_crl => global + .err( + ErrorCode::ERR_CRYPTO_OPERATION_FAILED, + format_args!("Failed to parse CRL"), + ) + .to_js(), } } diff --git a/src/uws_sys/SocketContext.rs b/src/uws_sys/SocketContext.rs index 01c214b0b098..3755ceaba233 100644 --- a/src/uws_sys/SocketContext.rs +++ b/src/uws_sys/SocketContext.rs @@ -111,6 +111,8 @@ pub struct BunSocketContextOptions { pub cert_count: u32, pub ca: *const *const c_char, pub ca_count: u32, + pub crl: *const *const c_char, + pub crl_count: u32, pub secure_options: u32, pub ssl_min_version: i32, pub ssl_max_version: i32, @@ -136,6 +138,8 @@ impl Default for BunSocketContextOptions { cert_count: 0, ca: ptr::null(), ca_count: 0, + crl: ptr::null(), + crl_count: 0, secure_options: 0, ssl_min_version: 0, ssl_max_version: 0, @@ -236,6 +240,7 @@ impl BunSocketContextOptions { feed_arr(&mut h, self.key, self.key_count); feed_arr(&mut h, self.cert, self.cert_count); feed_arr(&mut h, self.ca, self.ca_count); + feed_arr(&mut h, self.crl, self.crl_count); h.update(bun_core::bytes_of(&self.secure_options)); h.update(bun_core::bytes_of(&self.ssl_min_version)); h.update(bun_core::bytes_of(&self.ssl_max_version)); @@ -268,6 +273,7 @@ impl BunSocketContextOptions { sum(self.key, self.key_count, &mut n); sum(self.cert, self.cert_count, &mut n); sum(self.ca, self.ca_count, &mut n); + sum(self.crl, self.crl_count, &mut n); n } } diff --git a/src/uws_sys/lib.rs b/src/uws_sys/lib.rs index 164c88481c81..13a41095596a 100644 --- a/src/uws_sys/lib.rs +++ b/src/uws_sys/lib.rs @@ -108,6 +108,7 @@ pub enum create_bun_socket_error_t { invalid_ca_file, invalid_ca, invalid_ciphers, + invalid_crl, } impl create_bun_socket_error_t { @@ -118,6 +119,7 @@ impl create_bun_socket_error_t { Self::invalid_ca_file => Some(b"Invalid CA file"), Self::invalid_ca => Some(b"Invalid CA"), Self::invalid_ciphers => Some(b"Invalid ciphers"), + Self::invalid_crl => Some(b"Failed to parse CRL"), } } } diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 5c60031fc8dc..88c52c9f448f 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1266,6 +1266,126 @@ describe("tls.Server socket destroySoon", () => { }); }); +describe("tls.createServer crl option", () => { + const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); + // agent4's serial number is listed in ca2-crl.pem; agent3's is not. + const ca2 = keys("ca2-cert.pem"); + const ca2Crl = keys("ca2-crl.pem"); + + async function handshake(serverOpts: tls.TlsOptions, clientCert: string, clientKey: string) { + const result = Promise.withResolvers<{ authorized: boolean; authorizationError: unknown }>(); + const server: Server = createServer(serverOpts, socket => { + result.resolve({ authorized: socket.authorized, authorizationError: socket.authorizationError }); + socket.end(); + }); + // Bun also emits tlsClientError for verify failures before running the + // connection handler when rejectUnauthorized is false; the handler still + // runs with authorized=false, which is what this helper asserts on. + server.on("tlsClientError", () => {}); + server.on("error", result.reject); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ + port, + host: "127.0.0.1", + key: keys(clientKey), + cert: keys(clientCert), + rejectUnauthorized: false, + }); + client.on("error", () => {}); + try { + return await result.promise; + } finally { + client.destroy(); + server.close(); + await once(server, "close"); + } + } + + const serverBase = { + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + ca: ca2, + requestCert: true, + rejectUnauthorized: false, + }; + + it("rejects a client certificate listed in the CRL", async () => { + const revoked = await handshake({ ...serverBase, crl: ca2Crl }, "agent4-cert.pem", "agent4-key.pem"); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + + it("accepts a client certificate not listed in the CRL", async () => { + const valid = await handshake({ ...serverBase, crl: ca2Crl }, "agent3-cert.pem", "agent3-key.pem"); + expect(valid.authorized).toBe(true); + }); + + it("accepts crl as a PEM string", async () => { + const revoked = await handshake( + { ...serverBase, crl: ca2Crl.toString("utf8") }, + "agent4-cert.pem", + "agent4-key.pem", + ); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + + it("accepts crl as an array", async () => { + const revoked = await handshake({ ...serverBase, crl: [ca2Crl] }, "agent4-cert.pem", "agent4-key.pem"); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + + it("rejectUnauthorized:true refuses a revoked client certificate", async () => { + const result = Promise.withResolvers(); + const server: Server = createServer({ ...serverBase, crl: ca2Crl, rejectUnauthorized: true }, socket => { + // The connection handler only runs for authorized sockets when + // rejectUnauthorized is true; record the outcome so a missing CRL + // check surfaces as a test failure rather than a hang. + result.resolve(`handler ran authorized=${socket.authorized}`); + socket.end(); + }); + server.on("tlsClientError", err => result.resolve((err as NodeJS.ErrnoException).code ?? err.message)); + server.on("error", result.reject); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ + port, + host: "127.0.0.1", + key: keys("agent4-key.pem"), + cert: keys("agent4-cert.pem"), + rejectUnauthorized: false, + }); + client.on("error", () => {}); + let data = ""; + client.on("data", d => (data += d)); + try { + expect(await result.promise).toBe("CERT_REVOKED"); + await once(client, "close"); + expect(data).toBe(""); + } finally { + client.destroy(); + server.close(); + await once(server, "close"); + } + }); + + it("rejects an unparseable crl at secure-context creation", () => { + expect(() => tls.createSecureContext({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem"), ca: ca2, crl: "not a crl" })).toThrow( + expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" }), + ); + }); + + it("validates the crl option type", () => { + expect(() => createServer({ ...serverBase, crl: 123 as never })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => tls.createSecureContext({ crl: 123 as never })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +}); + it("tls.createServer honors secureOptions when negotiating the protocol version", async () => { const server: Server = createServer({ ...COMMON_CERT, secureOptions: crypto.constants.SSL_OP_NO_TLSv1_3 }); const accepted = Promise.withResolvers(); From 184b6e4ee1ae6664a396239b451a96401b21152c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:04:36 +0000 Subject: [PATCH 2/8] [autofix.ci] apply automated fixes --- test/js/node/tls/node-tls-server.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 88c52c9f448f..f72ecd5748b2 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1371,9 +1371,14 @@ describe("tls.createServer crl option", () => { }); it("rejects an unparseable crl at secure-context creation", () => { - expect(() => tls.createSecureContext({ key: keys("agent1-key.pem"), cert: keys("agent1-cert.pem"), ca: ca2, crl: "not a crl" })).toThrow( - expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" }), - ); + expect(() => + tls.createSecureContext({ + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + ca: ca2, + crl: "not a crl", + }), + ).toThrow(expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" })); }); it("validates the crl option type", () => { From 26d4c6ce4821ce647c0a3ccbb588911a6fd5119d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:17:55 +0000 Subject: [PATCH 3/8] address review: fail CRL load on any parse/store error, mark the context's store as user-owned add_crl_to_ctx_store now checks X509_STORE_add_crl's return and fails when the PEM loop terminates on anything other than PEM_R_NO_START_LINE, so a bundle with one valid CRL followed by garbage is rejected. The CRL block in us_ssl_ctx_build_raw now seeds a fresh default-roots store when the context's store is empty and sets us_ctx_user_ca_ex_idx, so the per-socket client attach in us_internal_ssl_attach does not override the verify store with the shared default roots and drop the CRL flags for a tls.connect({ crl }) without an explicit ca. Also register the client 'close' listener before awaiting the server result in the rejectUnauthorized:true test to avoid a listener-registration race. --- packages/bun-usockets/src/crypto/openssl.c | 30 +++++++++++++++++++--- test/js/node/tls/node-tls-server.test.ts | 3 ++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 155ec2e77915..29886d098c9e 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -767,7 +767,7 @@ static int us_ssl_ctx_use_privatekey_content(SSL_CTX *ctx, const char *content, /* node:tls `crl`: parse each X509 CRL block in `content` into `store` and * enable CRL checking on it (Node's SecureContext::AddCRL). Returns the number - * of CRLs added, 0 when no CRL could be parsed. */ + * of CRLs added, 0 when no CRL could be parsed or any block failed. */ static int add_crl_to_ctx_store(const char *content, X509_STORE *store) { int count = 0; X509_CRL *crl = NULL; @@ -779,12 +779,23 @@ static int add_crl_to_ctx_store(const char *content, X509_STORE *store) { return 0; } while ((crl = PEM_read_bio_X509_CRL(in, NULL, NULL, NULL))) { - X509_STORE_add_crl(store, crl); + int added = X509_STORE_add_crl(store, crl); X509_CRL_free(crl); + if (!added) { + BIO_free(in); + return 0; + } count++; } BIO_free(in); if (count > 0) { + /* PEM_R_NO_START_LINE terminates the loop when the BIO is exhausted; any + * other error means a later block was malformed. */ + unsigned long pem_err = ERR_peek_last_error(); + if (pem_err != 0 && !(ERR_GET_LIB(pem_err) == ERR_LIB_PEM && + ERR_GET_REASON(pem_err) == PEM_R_NO_START_LINE)) { + return 0; + } X509_STORE_set_flags(store, X509_V_FLAG_CRL_CHECK | X509_V_FLAG_CRL_CHECK_ALL); ERR_clear_error(); } @@ -1060,11 +1071,20 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, X509_STORE *store = SSL_CTX_get_cert_store(ssl_context); /* Clone-on-write: a CRL must not be attached to the process-wide default * root store (every other context would see it and start failing with - * UNABLE_TO_GET_CRL). Same check Node's SecureContext::AddCRL performs. */ + * UNABLE_TO_GET_CRL). Same check Node's SecureContext::AddCRL performs. + * A default context built without ca/requestCert still has the empty store + * from SSL_CTX_new(); give it its own default-roots copy so the CRL has a + * chain to check against and the per-socket client attach does not replace + * it with the shared store (which would drop the CRL flags). */ X509_STORE *shared = us_get_shared_default_ca_store(); int store_is_shared = store && store == shared; X509_STORE_free(shared); - if (store_is_shared) { + int store_is_empty = 0; + if (store && !store_is_shared) { + const STACK_OF(X509_OBJECT) *objs = X509_STORE_get0_objects(store); + store_is_empty = objs == NULL || sk_X509_OBJECT_num(objs) == 0; + } + if (store_is_shared || store_is_empty) { X509_STORE *own = us_get_default_ca_store(); if (!own) { ssl_ctx_build_fail(ssl_context); @@ -1073,6 +1093,8 @@ SSL_CTX *us_ssl_ctx_build_raw(struct us_bun_socket_context_options_t options, SSL_CTX_set_cert_store(ssl_context, own); store = own; } + us_ex_idx_ensure(); + SSL_CTX_set_ex_data(ssl_context, us_ctx_user_ca_ex_idx, (void *)1); for (unsigned int i = 0; i < options.crl_count; i++) { if (add_crl_to_ctx_store(options.crl[i], store) == 0) { *err = CREATE_BUN_SOCKET_ERROR_INVALID_CRL; diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index f72ecd5748b2..22b8ea773085 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1359,9 +1359,10 @@ describe("tls.createServer crl option", () => { client.on("error", () => {}); let data = ""; client.on("data", d => (data += d)); + const closed = once(client, "close"); try { expect(await result.promise).toBe("CERT_REVOKED"); - await once(client, "close"); + await closed; expect(data).toBe(""); } finally { client.destroy(); From 7e0a0ecddac42866495d0b6bb376285c22cde303 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:28:27 +0000 Subject: [PATCH 4/8] move crl tests to their own file node-tls-server.test.ts has a pre-existing unrelated failure on main ("SNICallback runs even when the requested servername matches the bind hostname" fails with ECONNREFUSED). Keep the CRL coverage in node-tls-crl.test.ts so it is isolated from that. --- test/js/node/tls/node-tls-crl.test.ts | 132 +++++++++++++++++++++++ test/js/node/tls/node-tls-server.test.ts | 126 ---------------------- 2 files changed, 132 insertions(+), 126 deletions(-) create mode 100644 test/js/node/tls/node-tls-crl.test.ts diff --git a/test/js/node/tls/node-tls-crl.test.ts b/test/js/node/tls/node-tls-crl.test.ts new file mode 100644 index 000000000000..0bb8bbd5cda1 --- /dev/null +++ b/test/js/node/tls/node-tls-crl.test.ts @@ -0,0 +1,132 @@ +import { readFileSync } from "fs"; +import { AddressInfo } from "net"; +import { once } from "node:events"; +import { join } from "path"; +import tls, { connect, createServer, Server } from "tls"; +import { describe, expect, it } from "bun:test"; + +const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); +// agent4's serial number is listed in ca2-crl.pem; agent3's is not. +const ca2 = keys("ca2-cert.pem"); +const ca2Crl = keys("ca2-crl.pem"); + +const serverBase = { + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + ca: ca2, + requestCert: true, + rejectUnauthorized: false, +}; + +async function handshake(serverOpts: tls.TlsOptions, clientCert: string, clientKey: string) { + const result = Promise.withResolvers<{ authorized: boolean; authorizationError: unknown }>(); + const server: Server = createServer(serverOpts, socket => { + result.resolve({ authorized: socket.authorized, authorizationError: socket.authorizationError }); + socket.end(); + }); + // Bun also emits tlsClientError for verify failures before running the + // connection handler when rejectUnauthorized is false; the handler still + // runs with authorized=false, which is what this helper asserts on. + server.on("tlsClientError", () => {}); + server.on("error", result.reject); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ + port, + host: "127.0.0.1", + key: keys(clientKey), + cert: keys(clientCert), + rejectUnauthorized: false, + }); + client.on("error", () => {}); + try { + return await result.promise; + } finally { + client.destroy(); + server.close(); + await once(server, "close"); + } +} + +describe("tls.createServer crl option", () => { + it("rejects a client certificate listed in the CRL", async () => { + const revoked = await handshake({ ...serverBase, crl: ca2Crl }, "agent4-cert.pem", "agent4-key.pem"); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + + it("accepts a client certificate not listed in the CRL", async () => { + const valid = await handshake({ ...serverBase, crl: ca2Crl }, "agent3-cert.pem", "agent3-key.pem"); + expect(valid.authorized).toBe(true); + }); + + it("accepts crl as a PEM string", async () => { + const revoked = await handshake( + { ...serverBase, crl: ca2Crl.toString("utf8") }, + "agent4-cert.pem", + "agent4-key.pem", + ); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + + it("accepts crl as an array", async () => { + const revoked = await handshake({ ...serverBase, crl: [ca2Crl] }, "agent4-cert.pem", "agent4-key.pem"); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + + it("rejectUnauthorized:true refuses a revoked client certificate", async () => { + const result = Promise.withResolvers(); + const server: Server = createServer({ ...serverBase, crl: ca2Crl, rejectUnauthorized: true }, socket => { + // The connection handler only runs for authorized sockets when + // rejectUnauthorized is true; record the outcome so a missing CRL + // check surfaces as a test failure rather than a hang. + result.resolve(`handler ran authorized=${socket.authorized}`); + socket.end(); + }); + server.on("tlsClientError", err => result.resolve((err as NodeJS.ErrnoException).code ?? err.message)); + server.on("error", result.reject); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = connect({ + port, + host: "127.0.0.1", + key: keys("agent4-key.pem"), + cert: keys("agent4-cert.pem"), + rejectUnauthorized: false, + }); + client.on("error", () => {}); + let data = ""; + client.on("data", d => (data += d)); + const closed = once(client, "close"); + try { + expect(await result.promise).toBe("CERT_REVOKED"); + await closed; + expect(data).toBe(""); + } finally { + client.destroy(); + server.close(); + await once(server, "close"); + } + }); + + it("rejects an unparseable crl at secure-context creation", () => { + expect(() => + tls.createSecureContext({ + key: keys("agent1-key.pem"), + cert: keys("agent1-cert.pem"), + ca: ca2, + crl: "not a crl", + }), + ).toThrow(expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" })); + }); + + it("validates the crl option type", () => { + expect(() => createServer({ ...serverBase, crl: 123 as never })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + expect(() => tls.createSecureContext({ crl: 123 as never })).toThrow( + expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), + ); + }); +}); diff --git a/test/js/node/tls/node-tls-server.test.ts b/test/js/node/tls/node-tls-server.test.ts index 22b8ea773085..5c60031fc8dc 100644 --- a/test/js/node/tls/node-tls-server.test.ts +++ b/test/js/node/tls/node-tls-server.test.ts @@ -1266,132 +1266,6 @@ describe("tls.Server socket destroySoon", () => { }); }); -describe("tls.createServer crl option", () => { - const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); - // agent4's serial number is listed in ca2-crl.pem; agent3's is not. - const ca2 = keys("ca2-cert.pem"); - const ca2Crl = keys("ca2-crl.pem"); - - async function handshake(serverOpts: tls.TlsOptions, clientCert: string, clientKey: string) { - const result = Promise.withResolvers<{ authorized: boolean; authorizationError: unknown }>(); - const server: Server = createServer(serverOpts, socket => { - result.resolve({ authorized: socket.authorized, authorizationError: socket.authorizationError }); - socket.end(); - }); - // Bun also emits tlsClientError for verify failures before running the - // connection handler when rejectUnauthorized is false; the handler still - // runs with authorized=false, which is what this helper asserts on. - server.on("tlsClientError", () => {}); - server.on("error", result.reject); - server.listen(0); - await once(server, "listening"); - const port = (server.address() as AddressInfo).port; - const client = connect({ - port, - host: "127.0.0.1", - key: keys(clientKey), - cert: keys(clientCert), - rejectUnauthorized: false, - }); - client.on("error", () => {}); - try { - return await result.promise; - } finally { - client.destroy(); - server.close(); - await once(server, "close"); - } - } - - const serverBase = { - key: keys("agent1-key.pem"), - cert: keys("agent1-cert.pem"), - ca: ca2, - requestCert: true, - rejectUnauthorized: false, - }; - - it("rejects a client certificate listed in the CRL", async () => { - const revoked = await handshake({ ...serverBase, crl: ca2Crl }, "agent4-cert.pem", "agent4-key.pem"); - expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); - }); - - it("accepts a client certificate not listed in the CRL", async () => { - const valid = await handshake({ ...serverBase, crl: ca2Crl }, "agent3-cert.pem", "agent3-key.pem"); - expect(valid.authorized).toBe(true); - }); - - it("accepts crl as a PEM string", async () => { - const revoked = await handshake( - { ...serverBase, crl: ca2Crl.toString("utf8") }, - "agent4-cert.pem", - "agent4-key.pem", - ); - expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); - }); - - it("accepts crl as an array", async () => { - const revoked = await handshake({ ...serverBase, crl: [ca2Crl] }, "agent4-cert.pem", "agent4-key.pem"); - expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); - }); - - it("rejectUnauthorized:true refuses a revoked client certificate", async () => { - const result = Promise.withResolvers(); - const server: Server = createServer({ ...serverBase, crl: ca2Crl, rejectUnauthorized: true }, socket => { - // The connection handler only runs for authorized sockets when - // rejectUnauthorized is true; record the outcome so a missing CRL - // check surfaces as a test failure rather than a hang. - result.resolve(`handler ran authorized=${socket.authorized}`); - socket.end(); - }); - server.on("tlsClientError", err => result.resolve((err as NodeJS.ErrnoException).code ?? err.message)); - server.on("error", result.reject); - server.listen(0); - await once(server, "listening"); - const port = (server.address() as AddressInfo).port; - const client = connect({ - port, - host: "127.0.0.1", - key: keys("agent4-key.pem"), - cert: keys("agent4-cert.pem"), - rejectUnauthorized: false, - }); - client.on("error", () => {}); - let data = ""; - client.on("data", d => (data += d)); - const closed = once(client, "close"); - try { - expect(await result.promise).toBe("CERT_REVOKED"); - await closed; - expect(data).toBe(""); - } finally { - client.destroy(); - server.close(); - await once(server, "close"); - } - }); - - it("rejects an unparseable crl at secure-context creation", () => { - expect(() => - tls.createSecureContext({ - key: keys("agent1-key.pem"), - cert: keys("agent1-cert.pem"), - ca: ca2, - crl: "not a crl", - }), - ).toThrow(expect.objectContaining({ code: "ERR_CRYPTO_OPERATION_FAILED" })); - }); - - it("validates the crl option type", () => { - expect(() => createServer({ ...serverBase, crl: 123 as never })).toThrow( - expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), - ); - expect(() => tls.createSecureContext({ crl: 123 as never })).toThrow( - expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), - ); - }); -}); - it("tls.createServer honors secureOptions when negotiating the protocol version", async () => { const server: Server = createServer({ ...COMMON_CERT, secureOptions: crypto.constants.SSL_OP_NO_TLSv1_3 }); const accepted = Promise.withResolvers(); From b12b749d78ab16dead110fd612dfde5fe92cdc8c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:30:31 +0000 Subject: [PATCH 5/8] [autofix.ci] apply automated fixes --- test/js/node/tls/node-tls-crl.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/node/tls/node-tls-crl.test.ts b/test/js/node/tls/node-tls-crl.test.ts index 0bb8bbd5cda1..1991d8b01e87 100644 --- a/test/js/node/tls/node-tls-crl.test.ts +++ b/test/js/node/tls/node-tls-crl.test.ts @@ -1,9 +1,9 @@ +import { describe, expect, it } from "bun:test"; import { readFileSync } from "fs"; import { AddressInfo } from "net"; import { once } from "node:events"; import { join } from "path"; import tls, { connect, createServer, Server } from "tls"; -import { describe, expect, it } from "bun:test"; const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); // agent4's serial number is listed in ca2-crl.pem; agent3's is not. From 1d04cd9c5227096f1b1435d4182e1de18c8eb7e5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:36:37 +0000 Subject: [PATCH 6/8] add BunFile crl input test --- test/js/node/tls/node-tls-crl.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/test/js/node/tls/node-tls-crl.test.ts b/test/js/node/tls/node-tls-crl.test.ts index 1991d8b01e87..f9ad2d06be15 100644 --- a/test/js/node/tls/node-tls-crl.test.ts +++ b/test/js/node/tls/node-tls-crl.test.ts @@ -5,7 +5,8 @@ import { once } from "node:events"; import { join } from "path"; import tls, { connect, createServer, Server } from "tls"; -const keys = (f: string) => readFileSync(join(import.meta.dir, "../test/fixtures/keys", f)); +const keyPath = (f: string) => join(import.meta.dir, "../test/fixtures/keys", f); +const keys = (f: string) => readFileSync(keyPath(f)); // agent4's serial number is listed in ca2-crl.pem; agent3's is not. const ca2 = keys("ca2-cert.pem"); const ca2Crl = keys("ca2-crl.pem"); @@ -74,6 +75,15 @@ describe("tls.createServer crl option", () => { expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); }); + it("accepts crl as a BunFile", async () => { + const revoked = await handshake( + { ...serverBase, crl: Bun.file(keyPath("ca2-crl.pem")) as never }, + "agent4-cert.pem", + "agent4-key.pem", + ); + expect(revoked).toEqual({ authorized: false, authorizationError: "CERT_REVOKED" }); + }); + it("rejectUnauthorized:true refuses a revoked client certificate", async () => { const result = Promise.withResolvers(); const server: Server = createServer({ ...serverBase, crl: ca2Crl, rejectUnauthorized: true }, socket => { From 65c6be90503d6215fc883d8f341eca49a8dbaf35 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:53:55 +0000 Subject: [PATCH 7/8] bindgen: append crl to the end of the SSLConfig dictionary and zero the extern buffer Appending keeps the existing field offsets in ExternSSLConfig unchanged so the hand-written #[repr(C)] mirror only grows at the tail. Zero-initialising the MaybeUninit buffer before bindgenConvertJSToSSLConfig writes into it means a stale generated header that under-writes the struct leaves the trailing field as its None variant instead of garbage; every field's all-zeros representation is valid. --- src/jsc/generated.rs | 7 +++++-- src/runtime/socket/SSLConfig.bindv2.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/jsc/generated.rs b/src/jsc/generated.rs index 170683bd4e61..8e044b6954cf 100644 --- a/src/jsc/generated.rs +++ b/src/jsc/generated.rs @@ -551,7 +551,6 @@ struct ExternSSLConfig { ca: ExternSSLConfigFile, cert: ExternSSLConfigFile, key: ExternSSLConfigFile, - crl: ExternSSLConfigFile, secure_options: u32, ssl_min_version: i32, ssl_max_version: i32, @@ -562,6 +561,7 @@ struct ExternSSLConfig { ciphers: RawWTFStringImpl, client_renegotiation_limit: u32, client_renegotiation_window: u32, + crl: ExternSSLConfigFile, } // safe: same handle/out-param contract as @@ -601,7 +601,10 @@ impl SSLConfig { } pub fn from_js(global: &JSGlobalObject, value: JSValue) -> JsResult { - let mut ext = MaybeUninit::::uninit(); + // Zeroed so an under-writing C++ side (e.g. a stale codegen header + // missing a trailing field) yields the field's `None` variant rather + // than garbage; every field's all-zeros representation is valid. + let mut ext = MaybeUninit::::zeroed(); crate::call_false_is_throw(global, || { bindgenConvertJSToSSLConfig(global, value, &mut ext) })?; diff --git a/src/runtime/socket/SSLConfig.bindv2.ts b/src/runtime/socket/SSLConfig.bindv2.ts index 854a4207c08b..bed23ee01e92 100644 --- a/src/runtime/socket/SSLConfig.bindv2.ts +++ b/src/runtime/socket/SSLConfig.bindv2.ts @@ -54,7 +54,6 @@ export const SSLConfig = b.dictionary( ca: SSLConfigFile, cert: SSLConfigFile, key: SSLConfigFile, - crl: SSLConfigFile, secureOptions: { type: b.u32, default: 0, @@ -97,5 +96,6 @@ export const SSLConfig = b.dictionary( default: 0, internalName: "client_renegotiation_window", }, + crl: SSLConfigFile, }, ); From b8ce22c11758c384c61046cae9da46bb0af00b4e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:00:34 +0000 Subject: [PATCH 8/8] types: add crl to Bun.TLSOptions --- packages/bun-types/bun.d.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 2a58e5f76e4b..4d61d8fc0366 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -4051,6 +4051,10 @@ declare module "bun" { * object.passphrase if provided, or options.passphrase if it is not. */ key?: string | BufferSource | BunFile | Array | undefined; + /** + * PEM formatted CRLs (Certificate Revocation Lists). + */ + crl?: string | BufferSource | BunFile | Array | undefined; /** * Optionally affect the OpenSSL protocol behavior, which is not * usually necessary. Use it carefully, if at all. Value is a numeric