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();
+ }));
+}));