Commit 9ccbc81ecee for nodejs

commit 9ccbc81eceeb4bd3316936731b0f0a01ec379e05
Author: Matteo Collina <hello@matteocollina.com>
Date:   Tue Sep 15 10:58:38 2026 +0200

    tls: reject non-canonical IP addresses

    TLS identity checks normalized non-canonical numeric hosts before
    matching certificates. Platform resolvers can interpret the original
    host as a different address.

    Reject hosts that become IP addresses only after domainToASCII()
    normalization.

    Assisted-by: Pi
    Signed-off-by: Matteo Collina <hello@matteocollina.com>
    PR-URL: https://github.com/nodejs-private/node-private/pull/982
    Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com>
    CVE-ID: CVE-2026-106069
    CVE-ID: CVE-2026-106072

diff --git a/lib/tls.js b/lib/tls.js
index 42562108eaa..ba395be5eb3 100644
--- a/lib/tls.js
+++ b/lib/tls.js
@@ -439,6 +439,8 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
   // Remove trailing dots for error messages and matching.
   hostname = unfqdn(hostname);
   const hostnameASCIIWithoutFQDN = unfqdn(hostnameASCII);
+  const hostnameIsIP = net.isIP(hostname);
+  const hostnameASCIIIsIP = net.isIP(hostnameASCIIWithoutFQDN);

   if (altNames) {
     const splitAltNames = altNames.includes('"') ?
@@ -453,29 +455,25 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
     });
   }

+  // Non-canonical IP address spellings can be interpreted differently by
+  // domainToASCII() and the operating system resolver. Reject them instead of
+  // validating a certificate against an address that may not be the peer.
+  if (!hostnameIsIP && hostnameASCIIIsIP) {
+    return new ERR_TLS_CERT_ALTNAME_INVALID(
+      'Host is a non-canonical IP address', hostname, cert);
+  }
+
   let valid = false;
   let reason = 'Unknown reason';

-  // Decide whether the host is an IP address by checking both forms of it,
-  // because neither form is enough on its own:
-  //
-  // - The original hostname is needed for IPv6. domainToASCII() returns '' for
-  //   an IPv6 literal such as '::1' (it is not a domain).
-  // - The normalized hostname is needed for hosts that net.isIP() rejects but
-  //   that normalize to an IPv4 address: '\uFF11\uFF12\uFF17.0.0.1' (fullwidth
-  //   digits), '127\u30020.0.1' (ideographic full stop) and '127.1' all become
-  //   '127.0.0.1'. The resolver treats them as that address too, so they must
-  //   be matched against IP SANs. Letting them fall through to the DNS-name
-  //   path would match an IP address against DNS names and the CN instead.
-  let ip = '';
-  if (net.isIP(hostname)) {
-    ip = hostname;
-  } else if (net.isIP(hostnameASCIIWithoutFQDN)) {
-    ip = hostnameASCIIWithoutFQDN;
-  }
-
-  if (ip) {
-    valid = ips.includes(canonicalizeIP(ip));
+  // An IP literal must not be IDNA-normalized: domainToASCII() returns '' for
+  // an IPv6 literal (it is not a domain), so matching against the normalized
+  // host would skip IP-SAN matching for IPv6 entirely. Match IP hosts against
+  // the original hostname (net.isIP() rejects non-ASCII, so there is no IDNA
+  // confusion to guard against here); the normalized form is kept for the
+  // DNS-name path below.
+  if (hostnameIsIP) {
+    valid = ips.includes(canonicalizeIP(hostname));
     if (!valid) {
       reason =
         `IP: ${hostname} is not in the cert's list: ` + ips.join(', ');
diff --git a/test/fixtures/keys/Makefile b/test/fixtures/keys/Makefile
index 605acf75afb..c7f444bf69e 100644
--- a/test/fixtures/keys/Makefile
+++ b/test/fixtures/keys/Makefile
@@ -110,6 +110,8 @@ all: \
   incorrect_san_correct_subject-key.pem \
   irrelevant_san_correct_subject-cert.pem \
   irrelevant_san_correct_subject-key.pem \
+  non-canonical-ip-dns-cert.pem \
+  non-canonical-ip-dns-key.pem \
   ml_dsa_44_private.pem \
   ml_dsa_44_private_seed_only.pem \
   ml_dsa_44_private_priv_only.pem \
@@ -1287,6 +1289,28 @@ name-constraints-%-cert.pem: name-constraints.cnf name-constraints-ca-cert.pem c
 		-extensions $* -out $@
 	rm name-constraints-$*-csr.pem

+non-canonical-ip-dns-cert.pem: non-canonical-ip-dns-key.pem ca1-cert.pem ca1-key.pem
+	openssl req -new \
+	            -key non-canonical-ip-dns-key.pem \
+	            -out non-canonical-ip-dns-cert.csr \
+	            -sha256 \
+	            -subj "/CN=Example Operator" \
+	            -addext "subjectAltName = DNS:87.0.0.1"
+	openssl x509 -req \
+	             -in non-canonical-ip-dns-cert.csr \
+	             -CA ca1-cert.pem \
+	             -CAkey ca1-key.pem \
+	             -passin "pass:password" \
+	             -set_serial 0x1001 \
+	             -out non-canonical-ip-dns-cert.pem \
+	             -days 99999 \
+	             -copy_extensions copy \
+	             -sha256
+	rm non-canonical-ip-dns-cert.csr
+
+non-canonical-ip-dns-key.pem:
+	openssl ecparam -name prime256v1 -genkey -noout -out non-canonical-ip-dns-key.pem
+
 clean:
 	rm -f *.pfx *.pem *.srl ca2-database.txt ca2-crl-agent3-database.txt* ca2-crl-agent3.cnf ca2-serial fake-startcom-root-serial *.print *.old fake-startcom-root-issued-certs/*.pem
 	@> fake-startcom-root-database.txt
diff --git a/test/fixtures/keys/non-canonical-ip-dns-cert.pem b/test/fixtures/keys/non-canonical-ip-dns-cert.pem
new file mode 100644
index 00000000000..8d074ee5e15
--- /dev/null
+++ b/test/fixtures/keys/non-canonical-ip-dns-cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDJDCCAgygAwIBAgICEAEwDQYJKoZIhvcNAQELBQAwejELMAkGA1UEBhMCVVMx
+CzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0GA1UECgwGSm95ZW50MRAwDgYD
+VQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAeBgkqhkiG9w0BCQEWEXJ5QHRp
+bnljbG91ZHMub3JnMCAXDTI2MDkxNTA4MjM0MloYDzIzMDAwNjMwMDgyMzQyWjAb
+MRkwFwYDVQQDDBBFeGFtcGxlIE9wZXJhdG9yMFkwEwYHKoZIzj0CAQYIKoZIzj0D
+AQcDQgAEtZoLwubUMsMLAtfUgQc7W5WzBW9Y1nq9DTma/F5tUnS7E9nZdnaFd5bj
+hofRf5qjd5FyduDgJU0ABKYHVph8/KOB2zCB2DATBgNVHREEDDAKggg4Ny4wLjAu
+MTAdBgNVHQ4EFgQUUlN/+oxGG+tfBUkKQX1RBmAl0DwwgaEGA1UdIwSBmTCBlqF+
+pHwwejELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0G
+A1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAe
+BgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnghRKsWyN/Wp9DS38q9+cSw6S
+xq0CKTANBgkqhkiG9w0BAQsFAAOCAQEApYHYIBczeRxLk1RO1uQICIXtiO1stPu+
+Q7PuFAJsv3n8+jp93kBXQn/p7HsstEC0xZfgJ5AFepkBL+r4Nf6rj7+owhDCDpSI
+2zV62t8Rmh7gbSj2C6O7vUMowXK0RrLlAl9kWJBGAlQ9huLDdtPrbcv6LBEnuTZ2
+B/hJ02I2dVALwuei41R1Omd/mAn1dWozXqlRgF39EUUXWXK/Fd/d44J2cpokLxfs
+3OcMrXWY0dXUdPLCvmOB97egcnpy7APOVDXCRmYEzJktiaESkCgeoXMxXPE6oexz
+OFzMLtMAAPCh3orw06x9kfaZ/BXecWSbtsza8odbQlaQx9ZBBn8eew==
+-----END CERTIFICATE-----
diff --git a/test/fixtures/keys/non-canonical-ip-dns-key.pem b/test/fixtures/keys/non-canonical-ip-dns-key.pem
new file mode 100644
index 00000000000..a33bd0e2770
--- /dev/null
+++ b/test/fixtures/keys/non-canonical-ip-dns-key.pem
@@ -0,0 +1,5 @@
+-----BEGIN EC PRIVATE KEY-----
+MHcCAQEEICA0HmHFMqxNucO30myyMnWxrGy6kXnfP2hNyOhOvylJoAoGCCqGSM49
+AwEHoUQDQgAEtZoLwubUMsMLAtfUgQc7W5WzBW9Y1nq9DTma/F5tUnS7E9nZdnaF
+d5bjhofRf5qjd5FyduDgJU0ABKYHVph8/A==
+-----END EC PRIVATE KEY-----
diff --git a/test/parallel/test-tls-check-server-identity.js b/test/parallel/test-tls-check-server-identity.js
index 61a2513f81d..2097081816e 100644
--- a/test/parallel/test-tls-check-server-identity.js
+++ b/test/parallel/test-tls-check-server-identity.js
@@ -394,46 +394,43 @@ const tests = [
     },
     error: 'IP: 127.0.0.1 is not in the cert\'s list: '
   },
-  // A host that net.isIP() rejects but that normalizes to an IPv4 address is
-  // an IP address, not a DNS name: it is matched against "IP Address:" SANs,
-  // never against DNS names or the CN.
-  {
-    host: '\uFF11\uFF12\uFF17.0.0.1', // "127.0.0.1" with fullwidth digits.
-    cert: { subjectaltname: 'DNS:127.0.0.1', subject: {} },
-    error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: '
-  },
-  {
-    host: '\uFF11\uFF12\uFF17.0.0.1',
-    cert: { subjectaltname: 'DNS:*.0.0.1', subject: {} },
-    error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: '
-  },
-  {
-    host: '\uFF11\uFF12\uFF17.0.0.1',
-    cert: { subject: { CN: '127.0.0.1' } },
-    error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: '
-  },
-  {
-    host: '\uFF11\uFF12\uFF17.0.0.1',
-    cert: { subjectaltname: 'IP Address:127.0.0.1', subject: {} }
-  },
-  {
-    host: '\uFF11\uFF12\uFF17.0.0.1',
-    cert: { subjectaltname: 'IP Address:127.0.0.2', subject: {} },
-    error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: 127.0.0.2'
-  },
+  // Non-canonical IP addresses are rejected instead of being matched against
+  // an address produced by domainToASCII().
+  ...[
+    ['0127.0.0.1', '87.0.0.1'],
+    ['0177.0.0.1', '127.0.0.1'],
+    ['0x7f.0.0.1', '127.0.0.1'],
+    ['2130706433', '127.0.0.1'],
+    ['127.1', '127.0.0.1'],
+    ['\uFF11\uFF12\uFF17.0.0.1', '127.0.0.1'], // Fullwidth digits.
+    ['127\u30020.0.1', '127.0.0.1'], // Ideographic full stop.
+  ].map(([host, normalized]) => ({
+    host,
+    cert: {
+      subjectaltname: `DNS:${normalized}`,
+      subject: {},
+    },
+    error: 'Host is a non-canonical IP address',
+  })),
   {
-    host: '127\u30020.0.1', // "127.0.0.1" with an ideographic full stop.
-    cert: { subjectaltname: 'DNS:127.0.0.1', subject: {} },
-    error: 'IP: 127\u30020.0.1 is not in the cert\'s list: '
+    host: '0127.0.0.1', cert: {
+      subjectaltname: 'IP Address:87.0.0.1',
+      subject: { CN: '87.0.0.1' },
+    },
+    error: 'Host is a non-canonical IP address',
   },
   {
-    host: '127.1',
-    cert: { subjectaltname: 'DNS:127.0.0.1', subject: {} },
-    error: 'IP: 127.1 is not in the cert\'s list: '
+    host: '0127.0.0.1', cert: {
+      subjectaltname: 'IP Address:87.0.0.1',
+      subject: { CN: 'Example Operator' },
+    },
+    error: 'Host is a non-canonical IP address',
   },
   {
-    host: '127.1',
-    cert: { subjectaltname: 'IP Address:127.0.0.1', subject: {} }
+    host: '1.2.3.example', cert: {
+      subjectaltname: 'DNS:1.2.3.example',
+      subject: {},
+    },
   },
   {
     host: 'localhost', cert: {
diff --git a/test/parallel/test-tls-connect-non-canonical-ip.js b/test/parallel/test-tls-connect-non-canonical-ip.js
new file mode 100644
index 00000000000..27a924ada51
--- /dev/null
+++ b/test/parallel/test-tls-connect-non-canonical-ip.js
@@ -0,0 +1,33 @@
+'use strict';
+
+const common = require('../common');
+if (!common.hasCrypto)
+  common.skip('missing crypto');
+
+const assert = require('assert');
+const tls = require('tls');
+const fixtures = require('../common/fixtures');
+
+const key = fixtures.readKey('non-canonical-ip-dns-key.pem');
+const cert = fixtures.readKey('non-canonical-ip-dns-cert.pem');
+const ca = fixtures.readKey('ca1-cert.pem');
+const server = tls.createServer({ key, cert }, (socket) => socket.end());
+
+server.listen(0, '127.0.0.1', common.mustCall(() => {
+  const client = tls.connect({
+    host: '0127.0.0.1',
+    port: server.address().port,
+    ca,
+    rejectUnauthorized: true,
+    lookup: common.mustCall((hostname, options, callback) => {
+      assert.strictEqual(hostname, '0127.0.0.1');
+      assert.strictEqual(options.all, true);
+      callback(null, [{ address: '127.0.0.1', family: 4 }]);
+    }),
+  }, common.mustNotCall());
+
+  client.on('error', common.mustCall((error) => {
+    assert.strictEqual(error.code, 'ERR_TLS_CERT_ALTNAME_INVALID');
+    server.close();
+  }));
+}));