Skip to content

Commit d766356

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 beb4cd4 commit d766356

6 files changed

Lines changed: 128 additions & 1 deletion

File tree

‎lib/tls.js‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,8 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
410410
// Remove trailing dots for error messages and matching.
411411
hostname = unfqdn(hostname);
412412
const hostnameASCIIWithoutFQDN = unfqdn(hostnameASCII);
413+
const hostnameIsIP = net.isIP(hostname);
414+
const hostnameASCIIIsIP = net.isIP(hostnameASCIIWithoutFQDN);
413415

414416
if (altNames) {
415417
const splitAltNames = altNames.includes('"') ?
@@ -424,6 +426,14 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
424426
});
425427
}
426428

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

@@ -433,7 +443,7 @@ exports.checkServerIdentity = function checkServerIdentity(hostname, cert) {
433443
// the original hostname (net.isIP() rejects non-ASCII, so there is no IDNA
434444
// confusion to guard against here); the normalized form is kept for the
435445
// DNS-name path below.
436-
if (net.isIP(hostname)) {
446+
if (hostnameIsIP) {
437447
valid = ips.includes(canonicalizeIP(hostname));
438448
if (!valid) {
439449
reason =

‎test/fixtures/keys/Makefile‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ all: \
9898
incorrect_san_correct_subject-key.pem \
9999
irrelevant_san_correct_subject-cert.pem \
100100
irrelevant_san_correct_subject-key.pem \
101+
non-canonical-ip-dns-cert.pem \
102+
non-canonical-ip-dns-key.pem \
101103

102104
#
103105
# Create Certificate Authority: ca1
@@ -917,6 +919,28 @@ irrelevant_san_correct_subject-cert.pem: irrelevant_san_correct_subject-key.pem
917919
irrelevant_san_correct_subject-key.pem:
918920
openssl ecparam -name prime256v1 -genkey -noout -out irrelevant_san_correct_subject-key.pem
919921

922+
non-canonical-ip-dns-cert.pem: non-canonical-ip-dns-key.pem ca1-cert.pem ca1-key.pem
923+
openssl req -new \
924+
-key non-canonical-ip-dns-key.pem \
925+
-out non-canonical-ip-dns-cert.csr \
926+
-sha256 \
927+
-subj "/CN=Example Operator" \
928+
-addext "subjectAltName = DNS:87.0.0.1"
929+
openssl x509 -req \
930+
-in non-canonical-ip-dns-cert.csr \
931+
-CA ca1-cert.pem \
932+
-CAkey ca1-key.pem \
933+
-passin "pass:password" \
934+
-set_serial 0x1001 \
935+
-out non-canonical-ip-dns-cert.pem \
936+
-days 99999 \
937+
-copy_extensions copy \
938+
-sha256
939+
rm non-canonical-ip-dns-cert.csr
940+
941+
non-canonical-ip-dns-key.pem:
942+
openssl ecparam -name prime256v1 -genkey -noout -out non-canonical-ip-dns-key.pem
943+
920944
clean:
921945
rm -f *.pfx *.pem *.srl ca2-database.txt ca2-serial fake-startcom-root-serial *.print *.old fake-startcom-root-issued-certs/*.pem
922946
@> 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: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,42 @@ const tests = [
394394
},
395395
error: 'IP: 127.0.0.1 is not in the cert\'s list: '
396396
},
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+
].map(([host, normalized]) => ({
406+
host,
407+
cert: {
408+
subjectaltname: `DNS:${normalized}`,
409+
subject: {},
410+
},
411+
error: 'Host is a non-canonical IP address',
412+
})),
413+
{
414+
host: '0127.0.0.1', cert: {
415+
subjectaltname: 'IP Address:87.0.0.1',
416+
subject: { CN: '87.0.0.1' },
417+
},
418+
error: 'Host is a non-canonical IP address',
419+
},
420+
{
421+
host: '0127.0.0.1', cert: {
422+
subjectaltname: 'IP Address:87.0.0.1',
423+
subject: { CN: 'Example Operator' },
424+
},
425+
error: 'Host is a non-canonical IP address',
426+
},
427+
{
428+
host: '1.2.3.example', cert: {
429+
subjectaltname: 'DNS:1.2.3.example',
430+
subject: {},
431+
},
432+
},
397433
{
398434
host: 'localhost', cert: {
399435
subjectaltname: 'DNS:a.com',
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)