Commit 20fd568f000 for nodejs

commit 20fd568f00072a10dbbfbb7145481a3cf56ae45d
Author: Matteo Collina <hello@matteocollina.com>
Date:   Mon Nov 2 17:41:11 2026 +0100

    https: isolate Agent TLS policy keys

    Encode TLS option types and values in HTTPS Agent keys so distinct trust
    and identity policies cannot share sockets or cached sessions. Assign
    stable identities to opaque SecureContext and callback objects.

    Refs: https://hackerone.com/reports/3946081

    Assisted-by: codex:gpt-5.6-sol
    Signed-off-by: Matteo Collina <hello@matteocollina.com>
    PR-URL: https://github.com/nodejs-private/node-private/pull/1036
    Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
    CVE-ID: CVE-2026-123877

diff --git a/lib/https.js b/lib/https.js
index 6be488aa753..38623d258a2 100644
--- a/lib/https.js
+++ b/lib/https.js
@@ -22,6 +22,7 @@
 'use strict';

 const {
+  ArrayBufferIsView,
   ArrayIsArray,
   ArrayPrototypeIndexOf,
   ArrayPrototypePush,
@@ -35,6 +36,8 @@ const {
   ObjectSetPrototypeOf,
   ReflectApply,
   ReflectConstruct,
+  SafeWeakMap,
+  String,
   Symbol,
   SymbolAsyncDispose,
 } = primordials;
@@ -54,6 +57,8 @@ const {
 } = require('internal/tls/secure-context');
 const kPerRequestCheckServerIdentity = Symbol('per-request checkServerIdentity');
 let perRequestCheckServerIdentityIndex = 0;
+const tlsPolicyObjectIds = new SafeWeakMap();
+let tlsPolicyObjectId = 0;
 const {
   kProxyConfig,
   checkShouldUseProxy,
@@ -514,16 +519,65 @@ Agent.prototype.keepSocketAlive = function keepSocketAlive(socket) {
   return FunctionPrototypeCall(HttpAgent.prototype.keepSocketAlive, this, socket);
 };

-function getPfxAgentKey(pfx, passphrase) {
-  if (!ArrayIsArray(pfx))
-    return pfx;
+// Encode values with their type and length so distinct TLS policies cannot
+// collapse to the same Agent key. Use stable identities for opaque objects.
+function getTlsOptionKey(value) {
+  if (value === undefined)
+    return '';
+  if (value === null)
+    return 'n';
+
+  if (ArrayIsArray(value)) {
+    let key = `a${value.length}`;
+    for (let i = 0; i < value.length; i++) {
+      const itemKey = getTlsOptionKey(value[i]);
+      key += `:${itemKey.length}:${itemKey}`;
+    }
+    return key;
+  }
+
+  if (ArrayBufferIsView(value)) {
+    const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
+    return `v${bytes.byteLength}:${bytes.toString('base64')}`;
+  }
+
+  const type = typeof value;
+  if (type === 'object' || type === 'function') {
+    let id = tlsPolicyObjectIds.get(value);
+    if (id === undefined) {
+      id = ++tlsPolicyObjectId;
+      tlsPolicyObjectIds.set(value, id);
+    }
+    return `o${id}`;
+  }
+
+  const string = String(value);
+  return `${type[0]}${string.length}:${string}`;
+}
+
+function getKeyMaterialAgentKey(value, passphrase, property,
+                                useTruthyProperties) {
+  if (!ArrayIsArray(value)) {
+    return `${getTlsOptionKey(value)}:${getTlsOptionKey(passphrase)}`;
+  }

-  let key = '';
-  for (let i = 0; i < pfx.length; i++) {
-    const value = pfx[i];
-    const raw = value?.buf || value;
-    const pass = value?.passphrase || passphrase;
-    key += `:${raw}:${pass}`;
+  let key = `a${value.length}`;
+  for (let i = 0; i < value.length; i++) {
+    const item = value[i];
+    let raw = item;
+    let pass = passphrase;
+    if (item !== null && typeof item === 'object' &&
+        !ArrayBufferIsView(item)) {
+      if (useTruthyProperties) {
+        raw = item[property] || item;
+        pass = item.passphrase || passphrase;
+      } else {
+        raw = item[property] !== undefined ? item[property] : item;
+        pass = item.passphrase !== undefined ? item.passphrase : passphrase;
+      }
+    }
+    const itemKey = `${getTlsOptionKey(raw)}:${getTlsOptionKey(pass)}`;
+    key += `:${itemKey.length}:${itemKey}`;
   }
   return key;
 }
@@ -544,23 +598,27 @@ Agent.prototype.getName = function getName(options = kEmptyObject) {

   name += ':';
   if (options.ca)
-    name += options.ca;
+    name += getTlsOptionKey(options.ca);

   name += ':';
   if (options.cert)
-    name += options.cert;
+    name += getTlsOptionKey(options.cert);

   name += ':';
   if (options.ciphers)
     name += options.ciphers;

   name += ':';
-  if (options.key)
-    name += options.key;
+  if (options.key) {
+    name += getKeyMaterialAgentKey(
+      options.key, options.passphrase, 'pem', false);
+  }

   name += ':';
-  if (options.pfx)
-    name += getPfxAgentKey(options.pfx, options.passphrase);
+  if (options.pfx) {
+    name += getKeyMaterialAgentKey(
+      options.pfx, options.passphrase, 'buf', true);
+  }

   name += ':';
   if (options.rejectUnauthorized !== undefined)
@@ -584,7 +642,7 @@ Agent.prototype.getName = function getName(options = kEmptyObject) {

   name += ':';
   if (options.crl)
-    name += options.crl;
+    name += getTlsOptionKey(options.crl);

   name += ':';
   if (options.honorCipherOrder !== undefined)
@@ -610,6 +668,38 @@ Agent.prototype.getName = function getName(options = kEmptyObject) {
   if (options.sigalgs)
     name += JSONStringify(options.sigalgs);

+  name += ':';
+  if (options.secureContext)
+    name += getTlsOptionKey(options.secureContext);
+
+  name += ':';
+  if (options.allowPartialTrustChain !== undefined)
+    name += getTlsOptionKey(options.allowPartialTrustChain);
+
+  name += ':';
+  if (options.ALPNProtocols)
+    name += getTlsOptionKey(options.ALPNProtocols);
+
+  name += ':';
+  if (options.certificateCompression)
+    name += getTlsOptionKey(options.certificateCompression);
+
+  name += ':';
+  if (options.minDHSize !== undefined)
+    name += getTlsOptionKey(options.minDHSize);
+
+  name += ':';
+  if (options.pskCallback)
+    name += getTlsOptionKey(options.pskCallback);
+
+  name += ':';
+  if (options.requestOCSP !== undefined)
+    name += getTlsOptionKey(options.requestOCSP);
+
+  name += ':';
+  if (options.session)
+    name += getTlsOptionKey(options.session);
+
   if (options[kPerRequestCheckServerIdentity])
     name += `:${options[kPerRequestCheckServerIdentity]}`;

diff --git a/test/parallel/test-https-agent-getname.js b/test/parallel/test-https-agent-getname.js
index 27dca59fec6..4b6555724b0 100644
--- a/test/parallel/test-https-agent-getname.js
+++ b/test/parallel/test-https-agent-getname.js
@@ -6,6 +6,7 @@ if (!common.hasCrypto)

 const assert = require('assert');
 const https = require('https');
+const tls = require('tls');
 const fixtures = require('../common/fixtures');

 const agent = new https.Agent();
@@ -13,13 +14,13 @@ const agent = new https.Agent();
 // empty argument
 assert.strictEqual(
   agent.getName(),
-  'localhost:::::::::::::::::::'
+  'localhost:::::::::::::::::::::::::::'
 );

 // empty options
 assert.strictEqual(
   agent.getName({}),
-  'localhost:::::::::::::::::::'
+  'localhost:::::::::::::::::::::::::::'
 );

 // Pass all options arguments
@@ -46,9 +47,10 @@ const options = {

 assert.strictEqual(
   agent.getName(options),
-  '0.0.0.0:443:192.168.1.1:ca:cert:ciphers:key:pfx:false:localhost:' +
-    '::secureProtocol:c,r,l:false:ecdhCurve:dhparam:0:sessionIdContext:' +
-    '"sigalgs"'
+  '0.0.0.0:443:192.168.1.1:s2:ca:s4:cert:ciphers:s3:key::' +
+    's3:pfx::false:localhost:::secureProtocol:' +
+    'a3:7:v1:Yw==:7:v1:cg==:7:v1:bA==:false:ecdhCurve:dhparam:0:' +
+    'sessionIdContext:"sigalgs"::::::::'
 );

 {
@@ -92,4 +94,61 @@ assert.strictEqual(
       pfx: [{ __proto__: { buf: agent6, passphrase: 'sample' } }],
     })
   );
+
+  assert.notStrictEqual(
+    agent.getName(baseOptions),
+    agent.getName({ ...baseOptions, ca: [] })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, allowPartialTrustChain: true }),
+    agent.getName({ ...baseOptions, allowPartialTrustChain: false })
+  );
+
+  const secureContext1 = tls.createSecureContext({ ca: agent1 });
+  const secureContext2 = tls.createSecureContext({ ca: agent6 });
+  assert.strictEqual(
+    agent.getName({ ...baseOptions, secureContext: secureContext1 }),
+    agent.getName({ ...baseOptions, secureContext: secureContext1 })
+  );
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, secureContext: secureContext1 }),
+    agent.getName({ ...baseOptions, secureContext: secureContext2 })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({
+      ...baseOptions,
+      key: [{ pem: agent1, passphrase: 'sample' }],
+    }),
+    agent.getName({
+      ...baseOptions,
+      key: [{ pem: agent6, passphrase: 'sample' }],
+    })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, ciphers: 'A:B' }),
+    agent.getName({ ...baseOptions, ciphers: 'A', key: 'B' })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, ALPNProtocols: ['h2'] }),
+    agent.getName({ ...baseOptions, ALPNProtocols: ['http/1.1'] })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, minDHSize: 1024 }),
+    agent.getName({ ...baseOptions, minDHSize: 2048 })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, pskCallback() {} }),
+    agent.getName({ ...baseOptions, pskCallback() {} })
+  );
+
+  assert.notStrictEqual(
+    agent.getName({ ...baseOptions, session: Buffer.from('one') }),
+    agent.getName({ ...baseOptions, session: Buffer.from('two') })
+  );
 }
diff --git a/test/parallel/test-https-agent-tls-policy-reuse.js b/test/parallel/test-https-agent-tls-policy-reuse.js
new file mode 100644
index 00000000000..81cc8d4e149
--- /dev/null
+++ b/test/parallel/test-https-agent-tls-policy-reuse.js
@@ -0,0 +1,213 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const { once } = require('events');
+const https = require('https');
+const tls = require('tls');
+const fixtures = require('../common/fixtures');
+
+const keys = {
+  agent1Cert: fixtures.readKey('agent1-cert.pem'),
+  agent1Key: fixtures.readKey('agent1-key.pem'),
+  agent3Cert: fixtures.readKey('agent3-cert.pem'),
+  agent3Key: fixtures.readKey('agent3-key.pem'),
+  agent6Cert: fixtures.readKey('agent6-cert.pem'),
+  agent6Key: fixtures.readKey('agent6-key.pem'),
+  ca1: fixtures.readKey('ca1-cert.pem'),
+  ca2: fixtures.readKey('ca2-cert.pem'),
+  ca3: fixtures.readKey('ca3-cert.pem'),
+};
+
+function request(server, agent, mode, options = {}) {
+  return new Promise((resolve, reject) => {
+    const headers = mode === 'session' ? { connection: 'close' } : undefined;
+    const req = https.get({
+      host: '127.0.0.1',
+      port: server.address().port,
+      agent,
+      headers,
+      ...options,
+    }, (res) => {
+      const isSessionReused = res.socket.isSessionReused();
+      let body = '';
+      res.setEncoding('utf8');
+      res.on('data', (chunk) => body += chunk);
+      res.on('end', () => resolve({
+        body,
+        isSessionReused,
+        reusedSocket: req.reusedSocket,
+      }));
+    });
+    req.on('error', reject);
+  });
+}
+
+async function settleAgent() {
+  await new Promise((resolve) => setImmediate(resolve));
+}
+
+async function runModes(fn, agentOptions = {}) {
+  for (const mode of ['session', 'socket']) {
+    const agent = new https.Agent(mode === 'socket' ? {
+      keepAlive: true,
+      maxCachedSessions: 0,
+      ...agentOptions,
+    } : agentOptions);
+    try {
+      await fn(agent, mode);
+    } finally {
+      agent.destroy();
+    }
+  }
+}
+
+async function withServer(options, handler, fn) {
+  const server = https.createServer(options, handler);
+  server.on('tlsClientError', () => {});
+  server.listen(0, '127.0.0.1');
+  await once(server, 'listening');
+  try {
+    await fn(server);
+  } finally {
+    server.close();
+    await once(server, 'close');
+  }
+}
+
+function isCertificateError(error) {
+  return error.code === 'SELF_SIGNED_CERT_IN_CHAIN' ||
+    error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' ||
+    error.code === 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY' ||
+    error.code === 'UNABLE_TO_GET_ISSUER_CERT';
+}
+
+async function testEmptyCA() {
+  const defaultCAs = tls.getCACertificates('default');
+  tls.setDefaultCACertificates([keys.ca1]);
+  try {
+    await withServer({
+      key: keys.agent1Key,
+      cert: keys.agent1Cert,
+      minVersion: 'TLSv1.2',
+      maxVersion: 'TLSv1.2',
+    }, (req, res) => res.end('agent1'), common.mustCall(async (server) => {
+      await runModes(common.mustCall(async (agent, mode) => {
+        const first = await request(server, agent, mode, {
+          servername: 'agent1',
+        });
+        assert.strictEqual(first.body, 'agent1');
+        await settleAgent();
+
+        await assert.rejects(request(server, agent, mode, {
+          servername: 'agent1',
+          ca: [],
+        }), isCertificateError);
+      }, 2));
+    }));
+  } finally {
+    tls.setDefaultCACertificates(defaultCAs);
+  }
+}
+
+async function testSecureContextTrust() {
+  const trusted = tls.createSecureContext({ ca: keys.ca1 });
+  const restricted = tls.createSecureContext({ ca: keys.ca2 });
+
+  await withServer({
+    key: keys.agent1Key,
+    cert: keys.agent1Cert,
+    minVersion: 'TLSv1.2',
+    maxVersion: 'TLSv1.2',
+  }, (req, res) => res.end('agent1'), common.mustCall(async (server) => {
+    await runModes(common.mustCall(async (agent, mode) => {
+      const first = await request(server, agent, mode, {
+        servername: 'agent1',
+        secureContext: trusted,
+      });
+      assert.strictEqual(first.body, 'agent1');
+      await settleAgent();
+
+      await assert.rejects(request(server, agent, mode, {
+        servername: 'agent1',
+        secureContext: restricted,
+      }), isCertificateError);
+    }, 2));
+  }));
+}
+
+async function testSecureContextIdentity() {
+  const identity1 = tls.createSecureContext({
+    ca: keys.ca1,
+    key: keys.agent1Key,
+    cert: keys.agent1Cert,
+  });
+  const identity3 = tls.createSecureContext({
+    ca: keys.ca1,
+    key: keys.agent3Key,
+    cert: keys.agent3Cert,
+  });
+
+  await withServer({
+    key: keys.agent1Key,
+    cert: keys.agent1Cert,
+    ca: [keys.ca1, keys.ca2],
+    requestCert: true,
+    rejectUnauthorized: true,
+    minVersion: 'TLSv1.2',
+    maxVersion: 'TLSv1.2',
+  }, (req, res) => {
+    res.end(req.socket.getPeerCertificate().subject.CN);
+  }, common.mustCall(async (server) => {
+    await runModes(common.mustCall(async (agent, mode) => {
+      const first = await request(server, agent, mode, {
+        servername: 'agent1',
+        secureContext: identity1,
+      });
+      assert.strictEqual(first.body, 'agent1');
+      await settleAgent();
+
+      const second = await request(server, agent, mode, {
+        servername: 'agent1',
+        secureContext: identity3,
+      });
+      assert.strictEqual(second.body, 'agent3');
+      assert.strictEqual(second.isSessionReused, false);
+      assert.strictEqual(second.reusedSocket, false);
+    }, 2));
+  }));
+}
+
+async function testAllowPartialTrustChain() {
+  await withServer({
+    key: keys.agent6Key,
+    cert: keys.agent6Cert,
+    minVersion: 'TLSv1.2',
+    maxVersion: 'TLSv1.2',
+  }, (req, res) => res.end('agent6'), common.mustCall(async (server) => {
+    await runModes(common.mustCall(async (agent, mode) => {
+      const first = await request(server, agent, mode, {
+        servername: 'agent6',
+        ca: keys.ca3,
+        allowPartialTrustChain: true,
+      });
+      assert.strictEqual(first.body, 'agent6');
+      await settleAgent();
+
+      await assert.rejects(request(server, agent, mode, {
+        servername: 'agent6',
+        ca: keys.ca3,
+      }), isCertificateError);
+    }, 2), { checkServerIdentity() {} });
+  }));
+}
+
+(async function() {
+  await testEmptyCA();
+  await testSecureContextTrust();
+  await testSecureContextIdentity();
+  await testAllowPartialTrustChain();
+})().then(common.mustCall());