Skip to content

Commit 935918e

Browse files
mcollinaaduh95
authored andcommitted
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: nodejs-private/node-private#982 Reviewed-By: Antoine du Hamel <duhamelantoine1995@gmail.com> CVE-ID: CVE-2026-106069
1 parent de49c91 commit 935918e

6 files changed

Lines changed: 131 additions & 55 deletions

File tree

‎lib/tls.js‎

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,8 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
439439
// Remove trailing dots for error messages and matching.
440440
hostname = unfqdn(hostname);
441441
const hostnameASCIIWithoutFQDN = unfqdn(hostnameASCII);
442+
const hostnameIsIP = net.isIP(hostname);
443+
const hostnameASCIIIsIP = net.isIP(hostnameASCIIWithoutFQDN);
442444

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

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

459-
// Decide whether the host is an IP address by checking both forms of it,
460-
// because neither form is enough on its own:
461-
//
462-
// - The original hostname is needed for IPv6. domainToASCII() returns '' for
463-
// an IPv6 literal such as '::1' (it is not a domain).
464-
// - The normalized hostname is needed for hosts that net.isIP() rejects but
465-
// that normalize to an IPv4 address: '\uFF11\uFF12\uFF17.0.0.1' (fullwidth
466-
// digits), '127\u30020.0.1' (ideographic full stop) and '127.1' all become
467-
// '127.0.0.1'. The resolver treats them as that address too, so they must
468-
// be matched against IP SANs. Letting them fall through to the DNS-name
469-
// path would match an IP address against DNS names and the CN instead.
470-
let ip = '';
471-
if (net.isIP(hostname)) {
472-
ip = hostname;
473-
} else if (net.isIP(hostnameASCIIWithoutFQDN)) {
474-
ip = hostnameASCIIWithoutFQDN;
475-
}
476-
477-
if (ip) {
478-
valid = ips.includes(canonicalizeIP(ip));
469+
// An IP literal must not be IDNA-normalized: domainToASCII() returns '' for
470+
// an IPv6 literal (it is not a domain), so matching against the normalized
471+
// host would skip IP-SAN matching for IPv6 entirely. Match IP hosts against
472+
// the original hostname (net.isIP() rejects non-ASCII, so there is no IDNA
473+
// confusion to guard against here); the normalized form is kept for the
474+
// DNS-name path below.
475+
if (hostnameIsIP) {
476+
valid = ips.includes(canonicalizeIP(hostname));
479477
if (!valid) {
480478
reason =
481479
`IP: ${hostname} is not in the cert's list: ` + ips.join(', ');

‎test/fixtures/keys/Makefile‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ all: \
105105
incorrect_san_correct_subject-key.pem \
106106
irrelevant_san_correct_subject-cert.pem \
107107
irrelevant_san_correct_subject-key.pem \
108+
non-canonical-ip-dns-cert.pem \
109+
non-canonical-ip-dns-key.pem \
108110
ml_dsa_44_private.pem \
109111
ml_dsa_44_private_seed_only.pem \
110112
ml_dsa_44_private_priv_only.pem \
@@ -1258,6 +1260,28 @@ irrelevant_san_correct_subject-cert.pem: irrelevant_san_correct_subject-key.pem
12581260
irrelevant_san_correct_subject-key.pem:
12591261
openssl ecparam -name prime256v1 -genkey -noout -out irrelevant_san_correct_subject-key.pem
12601262

1263+
non-canonical-ip-dns-cert.pem: non-canonical-ip-dns-key.pem ca1-cert.pem ca1-key.pem
1264+
openssl req -new \
1265+
-key non-canonical-ip-dns-key.pem \
1266+
-out non-canonical-ip-dns-cert.csr \
1267+
-sha256 \
1268+
-subj "/CN=Example Operator" \
1269+
-addext "subjectAltName = DNS:87.0.0.1"
1270+
openssl x509 -req \
1271+
-in non-canonical-ip-dns-cert.csr \
1272+
-CA ca1-cert.pem \
1273+
-CAkey ca1-key.pem \
1274+
-passin "pass:password" \
1275+
-set_serial 0x1001 \
1276+
-out non-canonical-ip-dns-cert.pem \
1277+
-days 99999 \
1278+
-copy_extensions copy \
1279+
-sha256
1280+
rm non-canonical-ip-dns-cert.csr
1281+
1282+
non-canonical-ip-dns-key.pem:
1283+
openssl ecparam -name prime256v1 -genkey -noout -out non-canonical-ip-dns-key.pem
1284+
12611285
clean:
12621286
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
12631287
@> fake-startcom-root-database.txt
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDJDCCAgygAwIBAgICEAEwDQYJKoZIhvcNAQELBQAwejELMAkGA1UEBhMCVVMx
3+
CzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0GA1UECgwGSm95ZW50MRAwDgYD
4+
VQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAeBgkqhkiG9w0BCQEWEXJ5QHRp
5+
bnljbG91ZHMub3JnMCAXDTI2MDkxNTA4MjM0MloYDzIzMDAwNjMwMDgyMzQyWjAb
6+
MRkwFwYDVQQDDBBFeGFtcGxlIE9wZXJhdG9yMFkwEwYHKoZIzj0CAQYIKoZIzj0D
7+
AQcDQgAEtZoLwubUMsMLAtfUgQc7W5WzBW9Y1nq9DTma/F5tUnS7E9nZdnaFd5bj
8+
hofRf5qjd5FyduDgJU0ABKYHVph8/KOB2zCB2DATBgNVHREEDDAKggg4Ny4wLjAu
9+
MTAdBgNVHQ4EFgQUUlN/+oxGG+tfBUkKQX1RBmAl0DwwgaEGA1UdIwSBmTCBlqF+
10+
pHwwejELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0G
11+
A1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAe
12+
BgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnghRKsWyN/Wp9DS38q9+cSw6S
13+
xq0CKTANBgkqhkiG9w0BAQsFAAOCAQEApYHYIBczeRxLk1RO1uQICIXtiO1stPu+
14+
Q7PuFAJsv3n8+jp93kBXQn/p7HsstEC0xZfgJ5AFepkBL+r4Nf6rj7+owhDCDpSI
15+
2zV62t8Rmh7gbSj2C6O7vUMowXK0RrLlAl9kWJBGAlQ9huLDdtPrbcv6LBEnuTZ2
16+
B/hJ02I2dVALwuei41R1Omd/mAn1dWozXqlRgF39EUUXWXK/Fd/d44J2cpokLxfs
17+
3OcMrXWY0dXUdPLCvmOB97egcnpy7APOVDXCRmYEzJktiaESkCgeoXMxXPE6oexz
18+
OFzMLtMAAPCh3orw06x9kfaZ/BXecWSbtsza8odbQlaQx9ZBBn8eew==
19+
-----END CERTIFICATE-----
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
-----BEGIN EC PRIVATE KEY-----
2+
MHcCAQEEICA0HmHFMqxNucO30myyMnWxrGy6kXnfP2hNyOhOvylJoAoGCCqGSM49
3+
AwEHoUQDQgAEtZoLwubUMsMLAtfUgQc7W5WzBW9Y1nq9DTma/F5tUnS7E9nZdnaF
4+
d5bjhofRf5qjd5FyduDgJU0ABKYHVph8/A==
5+
-----END EC PRIVATE KEY-----

‎test/parallel/test-tls-check-server-identity.js‎

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -394,46 +394,43 @@ const tests = [
394394
},
395395
error: 'IP: 127.0.0.1 is not in the cert\'s list: '
396396
},
397-
// A host that net.isIP() rejects but that normalizes to an IPv4 address is
398-
// an IP address, not a DNS name: it is matched against "IP Address:" SANs,
399-
// never against DNS names or the CN.
400-
{
401-
host: '\uFF11\uFF12\uFF17.0.0.1', // "127.0.0.1" with fullwidth digits.
402-
cert: { subjectaltname: 'DNS:127.0.0.1', subject: {} },
403-
error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: '
404-
},
405-
{
406-
host: '\uFF11\uFF12\uFF17.0.0.1',
407-
cert: { subjectaltname: 'DNS:*.0.0.1', subject: {} },
408-
error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: '
409-
},
410-
{
411-
host: '\uFF11\uFF12\uFF17.0.0.1',
412-
cert: { subject: { CN: '127.0.0.1' } },
413-
error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: '
414-
},
415-
{
416-
host: '\uFF11\uFF12\uFF17.0.0.1',
417-
cert: { subjectaltname: 'IP Address:127.0.0.1', subject: {} }
418-
},
419-
{
420-
host: '\uFF11\uFF12\uFF17.0.0.1',
421-
cert: { subjectaltname: 'IP Address:127.0.0.2', subject: {} },
422-
error: 'IP: \uFF11\uFF12\uFF17.0.0.1 is not in the cert\'s list: 127.0.0.2'
423-
},
397+
// Non-canonical IP addresses are rejected instead of being matched against
398+
// an address produced by domainToASCII().
399+
...[
400+
['0127.0.0.1', '87.0.0.1'],
401+
['0177.0.0.1', '127.0.0.1'],
402+
['0x7f.0.0.1', '127.0.0.1'],
403+
['2130706433', '127.0.0.1'],
404+
['127.1', '127.0.0.1'],
405+
['\uFF11\uFF12\uFF17.0.0.1', '127.0.0.1'], // Fullwidth digits.
406+
['127\u30020.0.1', '127.0.0.1'], // Ideographic full stop.
407+
].map(([host, normalized]) => ({
408+
host,
409+
cert: {
410+
subjectaltname: `DNS:${normalized}`,
411+
subject: {},
412+
},
413+
error: 'Host is a non-canonical IP address',
414+
})),
424415
{
425-
host: '127\u30020.0.1', // "127.0.0.1" with an ideographic full stop.
426-
cert: { subjectaltname: 'DNS:127.0.0.1', subject: {} },
427-
error: 'IP: 127\u30020.0.1 is not in the cert\'s list: '
416+
host: '0127.0.0.1', cert: {
417+
subjectaltname: 'IP Address:87.0.0.1',
418+
subject: { CN: '87.0.0.1' },
419+
},
420+
error: 'Host is a non-canonical IP address',
428421
},
429422
{
430-
host: '127.1',
431-
cert: { subjectaltname: 'DNS:127.0.0.1', subject: {} },
432-
error: 'IP: 127.1 is not in the cert\'s list: '
423+
host: '0127.0.0.1', cert: {
424+
subjectaltname: 'IP Address:87.0.0.1',
425+
subject: { CN: 'Example Operator' },
426+
},
427+
error: 'Host is a non-canonical IP address',
433428
},
434429
{
435-
host: '127.1',
436-
cert: { subjectaltname: 'IP Address:127.0.0.1', subject: {} }
430+
host: '1.2.3.example', cert: {
431+
subjectaltname: 'DNS:1.2.3.example',
432+
subject: {},
433+
},
437434
},
438435
{
439436
host: 'localhost', cert: {
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
'use strict';
2+
3+
const common = require('../common');
4+
if (!common.hasCrypto)
5+
common.skip('missing crypto');
6+
7+
const assert = require('assert');
8+
const tls = require('tls');
9+
const fixtures = require('../common/fixtures');
10+
11+
const key = fixtures.readKey('non-canonical-ip-dns-key.pem');
12+
const cert = fixtures.readKey('non-canonical-ip-dns-cert.pem');
13+
const ca = fixtures.readKey('ca1-cert.pem');
14+
const server = tls.createServer({ key, cert }, (socket) => socket.end());
15+
16+
server.listen(0, '127.0.0.1', common.mustCall(() => {
17+
const client = tls.connect({
18+
host: '0127.0.0.1',
19+
port: server.address().port,
20+
ca,
21+
rejectUnauthorized: true,
22+
lookup: common.mustCall((hostname, options, callback) => {
23+
assert.strictEqual(hostname, '0127.0.0.1');
24+
assert.strictEqual(options.all, true);
25+
callback(null, [{ address: '127.0.0.1', family: 4 }]);
26+
}),
27+
}, common.mustNotCall());
28+
29+
client.on('error', common.mustCall((error) => {
30+
assert.strictEqual(error.code, 'ERR_TLS_CERT_ALTNAME_INVALID');
31+
server.close();
32+
}));
33+
}));

0 commit comments

Comments
 (0)