Repository: denoland/deno
Pull request: fix(ext/node): add tls.setDefaultCACertificates support

feat(node): implement tls.setDefaultCACertificates

related issue:
closes # [31208](https://github.com/denoland/deno/issues/31208)

The `tls.setDefaultCACertificates` API was missing in `node:tls`, causing `TypeError` in applications that rely on programmatically overriding the global CA store (e.g., for custom root CAs or testing environments).

How
• Added `NodeTlsState` to `OpState` in Rust to store custom certificates per-isolate, ensuring proper worker isolation and thread safety
• Updated `op_get_root_certificates` to check for custom certificates in `OpState` before falling back to system roots
• Implemented `tls.setDefaultCACertificates` in the polyfill to update the native state
• Added cache invalidation logic for `lazyRootCertificates` to ensure subsequent calls fetch the updated list
• Implemented in-place array clearing (`target.length = 0`) for the `rootCertificates` proxy to preserve object references while updating data


Changed files:
filename: ext/node/lib.rs
patch: @@ -390,6 +390,7 @@ deno_core::extension!(deno_node,
     ops::process::op_node_process_setuid,
     ops::process::op_process_abort,
     ops::tls::op_get_root_certificates,
+    ops::tls::op_set_default_ca_certificates,
     ops::tls::op_tls_peer_certificate,
     ops::tls::op_tls_canonicalize_ipv4_address,
     ops::tls::op_node_tls_start,

filename: ext/node/ops/tls.rs
patch: @@ -46,9 +46,21 @@ use webpki_root_certs;
 use super::crypto::x509::Certificate;
 use super::crypto::x509::CertificateObject;
 
+#[derive(Clone)]
+struct NodeTlsState {
+  custom_ca_certs: Option<Vec<String>>,
+}
+
 #[op2]
 #[serde]
-pub fn op_get_root_certificates() -> Vec<String> {
+pub fn op_get_root_certificates(state: &mut OpState) -> Vec<String> {
+  if let Some(tls_state) = state.try_borrow::<NodeTlsState>()
+    && let Some(certs) = &tls_state.custom_ca_certs
+  {
+    return certs.clone();
+  }
+
+  // Return default root certificates if no custom ones are set
   webpki_root_certs::TLS_SERVER_ROOT_CERTS
     .iter()
     .map(|cert| {
@@ -70,6 +82,20 @@ pub fn op_get_root_certificates() -> Vec<String> {
     .collect::<Vec<String>>()
 }
 
+#[op2]
+pub fn op_set_default_ca_certificates(
+  state: &mut OpState,
+  #[serde] certs: Vec<String>,
+) {
+  if let Some(tls_state) = state.try_borrow_mut::<NodeTlsState>() {
+    tls_state.custom_ca_certs = Some(certs);
+  } else {
+    state.put(NodeTlsState {
+      custom_ca_certs: Some(certs),
+    });
+  }
+}
+
 #[op2]
 #[serde]
 pub fn op_tls_peer_certificate(

filename: ext/node/polyfills/tls.ts
patch: @@ -7,7 +7,10 @@
 import { notImplemented } from "ext:deno_node/_utils.ts";
 import tlsCommon from "node:_tls_common";
 import tlsWrap from "node:_tls_wrap";
-import { op_get_root_certificates } from "ext:core/ops";
+import {
+  op_get_root_certificates,
+  op_set_default_ca_certificates,
+} from "ext:core/ops";
 import { primordials } from "ext:core/mod.js";
 
 const { ObjectFreeze } = primordials;
@@ -38,6 +41,8 @@ let lazyRootCertificates: string[] | null = null;
 function ensureLazyRootCertificates(target: string[]) {
   if (lazyRootCertificates === null) {
     lazyRootCertificates = op_get_root_certificates() as string[];
+    // Clear target and repopulate
+    target.length = 0;
     lazyRootCertificates.forEach((v) => target.push(v));
     ObjectFreeze(target);
   }
@@ -95,6 +100,27 @@ export const CLIENT_RENEG_WINDOW = 600;
 export class CryptoStream {}
 export class SecurePair {}
 export const Server = tlsWrap.Server;
+
+export function setDefaultCACertificates(certs: string[]) {
+  if (!Array.isArray(certs)) {
+    throw new TypeError(
+      "The argument 'certs' must be an array of strings",
+    );
+  }
+
+  for (const cert of certs) {
+    if (typeof cert !== "string") {
+      throw new TypeError(
+        "Each certificate in 'certs' must be a string",
+      );
+    }
+  }
+
+  op_set_default_ca_certificates(certs);
+
+  lazyRootCertificates = null;
+}
+
 export function createSecurePair() {
   notImplemented("tls.createSecurePair");
 }
@@ -111,6 +137,7 @@ export default {
   createServer: tlsWrap.createServer,
   getCiphers,
   rootCertificates,
+  setDefaultCACertificates,
   DEFAULT_CIPHERS: tlsWrap.DEFAULT_CIPHERS,
   DEFAULT_ECDH_CURVE,
   DEFAULT_MAX_VERSION,

filename: tests/unit_node/tls_test.ts
patch: @@ -441,3 +441,41 @@ Deno.test("mTLS client certificate authentication", async () => {
   assertEquals(result, "mTLS success!");
   server.close();
 });
+
+Deno.test("tls.setDefaultCACertificates exists", () => {
+  // deno-lint-ignore no-explicit-any
+  assertEquals(typeof (tls as any).setDefaultCACertificates, "function");
+});
+
+Deno.test("tls.setDefaultCACertificates validates input - must be array", () => {
+  assertThrows(
+    () => {
+      // deno-lint-ignore no-explicit-any
+      (tls as any).setDefaultCACertificates("not an array");
+    },
+    TypeError,
+    "must be an array",
+  );
+});
+
+Deno.test("tls.setDefaultCACertificates validates input - array elements must be strings", () => {
+  assertThrows(
+    () => {
+      // deno-lint-ignore no-explicit-any
+      (tls as any).setDefaultCACertificates([123, 456]);
+    },
+    TypeError,
+    "must be a string",
+  );
+});
+
+Deno.test("tls.setDefaultCACertificates accepts valid certificate array", () => {
+  const testCert = `-----BEGIN CERTIFICATE-----
+MIIBkTCB+wIJAKHHCgVZU1FFMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl
+c3RDQTAeFw0yMDAxMDEwMDAwMDBaFw0zMDAxMDEwMDAwMDBaMBExDzANBgNVBAMM
+BnRlc3RDQTCB
+-----END CERTIFICATE-----`;
+
+  // deno-lint-ignore no-explicit-any
+  (tls as any).setDefaultCACertificates([testCert]);
+});