Skip to content

Commit 3ff82de

Browse files
bnoordhuisrvagg
authored andcommitted
lib: make tls.checkServerIdentity() more strict
PR-URL: nodejs-private/node-private#63 Reviewed-By: Rod Vagg <rod@vagg.org>
1 parent b5c57ff commit 3ff82de

File tree

2 files changed

+172
-114
lines changed

2 files changed

+172
-114
lines changed

lib/tls.js

Lines changed: 121 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -56,134 +56,141 @@ exports.convertNPNProtocols = function convertNPNProtocols(NPNProtocols, out) {
5656
}
5757
};
5858

59-
exports.checkServerIdentity = function checkServerIdentity(host, cert) {
60-
// Create regexp to much hostnames
61-
function regexpify(host, wildcards) {
62-
// Add trailing dot (make hostnames uniform)
63-
if (!host || !host.endsWith('.')) host += '.';
64-
65-
// The same applies to hostname with more than one wildcard,
66-
// if hostname has wildcard when wildcards are not allowed,
67-
// or if there are less than two dots after wildcard (i.e. *.com or *d.com)
68-
//
69-
// also
70-
//
71-
// "The client SHOULD NOT attempt to match a presented identifier in
72-
// which the wildcard character comprises a label other than the
73-
// left-most label (e.g., do not match bar.*.example.net)."
74-
// RFC6125
75-
if (!wildcards && /\*/.test(host) || /[\.\*].*\*/.test(host) ||
76-
/\*/.test(host) && !/\*.*\..+\..+/.test(host)) {
77-
return /$./;
78-
}
79-
80-
// Replace wildcard chars with regexp's wildcard and
81-
// escape all characters that have special meaning in regexps
82-
// (i.e. '.', '[', '{', '*', and others)
83-
var re = host.replace(
84-
/\*([a-z0-9\\-_\.])|[\.,\-\\\^\$+?*\[\]\(\):!\|{}]/g,
85-
function(all, sub) {
86-
if (sub) return '[a-z0-9\\-_]*' + (sub === '-' ? '\\-' : sub);
87-
return '\\' + all;
88-
});
89-
90-
return new RegExp('^' + re + '$', 'i');
59+
function unfqdn(host) {
60+
return host.replace(/[.]$/, '');
61+
}
62+
63+
function splitHost(host) {
64+
// String#toLowerCase() is locale-sensitive so we use
65+
// a conservative version that only lowercases A-Z.
66+
const replacer = (c) => String.fromCharCode(32 + c.charCodeAt(0));
67+
return unfqdn(host).replace(/[A-Z]/g, replacer).split('.');
68+
}
69+
70+
function check(hostParts, pattern, wildcards) {
71+
// Empty strings, null, undefined, etc. never match.
72+
if (!pattern)
73+
return false;
74+
75+
const patternParts = splitHost(pattern);
76+
77+
if (hostParts.length !== patternParts.length)
78+
return false;
79+
80+
// Pattern has empty components, e.g. "bad..example.com".
81+
if (patternParts.indexOf('') !== -1)
82+
return false;
83+
84+
// RFC 6125 allows IDNA U-labels (Unicode) in names but we have no
85+
// good way to detect their encoding or normalize them so we simply
86+
// reject them. Control characters and blanks are rejected as well
87+
// because nothing good can come from accepting them.
88+
const isBad = (s) => /[^\u0021-\u007F]/.test(s);
89+
if (patternParts.some(isBad))
90+
return false;
91+
92+
// Check host parts from right to left first.
93+
for (let i = hostParts.length - 1; i > 0; i -= 1)
94+
if (hostParts[i] !== patternParts[i])
95+
return false;
96+
97+
const hostSubdomain = hostParts[0];
98+
const patternSubdomain = patternParts[0];
99+
const patternSubdomainParts = patternSubdomain.split('*');
100+
101+
// Short-circuit when the subdomain does not contain a wildcard.
102+
// RFC 6125 does not allow wildcard substitution for components
103+
// containing IDNA A-labels (Punycode) so match those verbatim.
104+
if (patternSubdomainParts.length === 1 ||
105+
patternSubdomain.indexOf('xn--') !== -1) {
106+
return hostSubdomain === patternSubdomain;
91107
}
92108

93-
var dnsNames = [];
94-
var uriNames = [];
109+
110+
if (!wildcards)
111+
return false;
112+
113+
// More than one wildcard is always wrong.
114+
if (patternSubdomainParts.length > 2)
115+
return false;
116+
117+
// *.tld wildcards are not allowed.
118+
if (patternParts.length <= 2)
119+
return false;
120+
121+
const prefix = patternSubdomainParts[0];
122+
const suffix = patternSubdomainParts[1];
123+
124+
if (prefix.length + suffix.length > hostSubdomain.length)
125+
return false;
126+
127+
if (!hostSubdomain.startsWith(prefix))
128+
return false;
129+
130+
if (!hostSubdomain.endsWith(suffix))
131+
return false;
132+
133+
return true;
134+
}
135+
136+
exports.checkServerIdentity = function checkServerIdentity(host, cert) {
137+
const subject = cert.subject;
138+
const altNames = cert.subjectaltname;
139+
const dnsNames = [];
140+
const uriNames = [];
95141
const ips = [];
96-
var matchCN = true;
97-
var valid = false;
98-
var reason = 'Unknown reason';
99-
100-
// There're several names to perform check against:
101-
// CN and altnames in certificate extension
102-
// (DNS names, IP addresses, and URIs)
103-
//
104-
// Walk through altnames and generate lists of those names
105-
if (cert.subjectaltname) {
106-
cert.subjectaltname.split(/, /g).forEach(function(altname) {
107-
var option = altname.match(/^(DNS|IP Address|URI):(.*)$/);
108-
if (!option)
109-
return;
110-
if (option[1] === 'DNS') {
111-
dnsNames.push(option[2]);
112-
} else if (option[1] === 'IP Address') {
113-
ips.push(option[2]);
114-
} else if (option[1] === 'URI') {
115-
var uri = url.parse(option[2]);
116-
if (uri) uriNames.push(uri.hostname);
117-
}
118-
});
119-
}
120142

121-
// If hostname is an IP address, it should be present in the list of IP
122-
// addresses.
123-
if (net.isIP(host)) {
124-
valid = ips.some(function(ip) {
125-
return ip === host;
126-
});
127-
if (!valid) {
128-
reason = `IP: ${host} is not in the cert's list: ${ips.join(', ')}`;
129-
}
130-
} else if (cert.subject) {
131-
// Transform hostname to canonical form
132-
if (!host || !host.endsWith('.')) host += '.';
133-
134-
// Otherwise check all DNS/URI records from certificate
135-
// (with allowed wildcards)
136-
dnsNames = dnsNames.map(function(name) {
137-
return regexpify(name, true);
138-
});
139-
140-
// Wildcards ain't allowed in URI names
141-
uriNames = uriNames.map(function(name) {
142-
return regexpify(name, false);
143-
});
144-
145-
dnsNames = dnsNames.concat(uriNames);
146-
147-
if (dnsNames.length > 0) matchCN = false;
148-
149-
// Match against Common Name (CN) only if no supported identifiers are
150-
// present.
151-
//
152-
// "As noted, a client MUST NOT seek a match for a reference identifier
153-
// of CN-ID if the presented identifiers include a DNS-ID, SRV-ID,
154-
// URI-ID, or any application-specific identifier types supported by the
155-
// client."
156-
// RFC6125
157-
if (matchCN) {
158-
var commonNames = cert.subject.CN;
159-
if (Array.isArray(commonNames)) {
160-
for (var i = 0, k = commonNames.length; i < k; ++i) {
161-
dnsNames.push(regexpify(commonNames[i], true));
162-
}
163-
} else {
164-
dnsNames.push(regexpify(commonNames, true));
143+
host = '' + host;
144+
145+
if (altNames) {
146+
for (const name of altNames.split(', ')) {
147+
if (name.startsWith('DNS:')) {
148+
dnsNames.push(name.slice(4));
149+
} else if (name.startsWith('URI:')) {
150+
const uri = url.parse(name.slice(4));
151+
uriNames.push(uri.hostname); // TODO(bnoordhuis) Also use scheme.
152+
} else if (name.startsWith('IP Address:')) {
153+
ips.push(name.slice(11));
165154
}
166155
}
156+
}
167157

168-
valid = dnsNames.some(function(re) {
169-
return re.test(host);
170-
});
158+
let valid = false;
159+
let reason = 'Unknown reason';
171160

172-
if (!valid) {
173-
if (cert.subjectaltname) {
174-
reason =
175-
`Host: ${host} is not in the cert's altnames: ` +
176-
`${cert.subjectaltname}`;
177-
} else {
178-
reason = `Host: ${host} is not cert's CN: ${cert.subject.CN}`;
179-
}
161+
if (net.isIP(host)) {
162+
valid = ips.indexOf(host) !== -1;
163+
if (!valid)
164+
reason = `IP: ${host} is not in the cert's list: ${ips.join(', ')}`;
165+
// TODO(bnoordhuis) Also check URI SANs that are IP addresses.
166+
} else if (subject) {
167+
host = unfqdn(host); // Remove trailing dot for error messages.
168+
const hostParts = splitHost(host);
169+
const wildcard = (pattern) => check(hostParts, pattern, true);
170+
const noWildcard = (pattern) => check(hostParts, pattern, false);
171+
172+
// Match against Common Name only if no supported identifiers are present.
173+
if (dnsNames.length === 0 && ips.length === 0 && uriNames.length === 0) {
174+
const cn = subject.CN;
175+
176+
if (Array.isArray(cn))
177+
valid = cn.some(wildcard);
178+
else if (cn)
179+
valid = wildcard(cn);
180+
181+
if (!valid)
182+
reason = `Host: ${host}. is not cert's CN: ${cn}`;
183+
} else {
184+
valid = dnsNames.some(wildcard) || uriNames.some(noWildcard);
185+
if (!valid)
186+
reason = `Host: ${host}. is not in the cert's altnames: ${altNames}`;
180187
}
181188
} else {
182189
reason = 'Cert is empty';
183190
}
184191

185192
if (!valid) {
186-
var err = new Error(
193+
const err = new Error(
187194
`Hostname/IP doesn't match certificate's altnames: "${reason}"`);
188195
err.reason = reason;
189196
err.host = host;

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,23 @@ var tls = require('tls');
1111

1212

1313
var tests = [
14+
// False-y values.
15+
{
16+
host: false,
17+
cert: { subject: { CN: 'a.com' } },
18+
error: 'Host: false. is not cert\'s CN: a.com'
19+
},
20+
{
21+
host: null,
22+
cert: { subject: { CN: 'a.com' } },
23+
error: 'Host: null. is not cert\'s CN: a.com'
24+
},
25+
{
26+
host: undefined,
27+
cert: { subject: { CN: 'a.com' } },
28+
error: 'Host: undefined. is not cert\'s CN: a.com'
29+
},
30+
1431
// Basic CN handling
1532
{ host: 'a.com', cert: { subject: { CN: 'a.com' } } },
1633
{ host: 'a.com', cert: { subject: { CN: 'A.COM' } } },
@@ -20,15 +37,35 @@ var tests = [
2037
error: 'Host: a.com. is not cert\'s CN: b.com'
2138
},
2239
{ host: 'a.com', cert: { subject: { CN: 'a.com.' } } },
40+
{
41+
host: 'a.com',
42+
cert: { subject: { CN: '.a.com' } },
43+
error: 'Host: a.com. is not cert\'s CN: .a.com'
44+
},
2345

2446
// Wildcards in CN
2547
{ host: 'b.a.com', cert: { subject: { CN: '*.a.com' } } },
48+
{
49+
host: 'ba.com',
50+
cert: { subject: { CN: '*.a.com' } },
51+
error: 'Host: ba.com. is not cert\'s CN: *.a.com'
52+
},
53+
{
54+
host: '\n.b.com',
55+
cert: { subject: { CN: '*n.b.com' } },
56+
error: 'Host: \n.b.com. is not cert\'s CN: *n.b.com'
57+
},
2658
{ host: 'b.a.com', cert: {
2759
subjectaltname: 'DNS:omg.com',
2860
subject: { CN: '*.a.com' } },
2961
error: 'Host: b.a.com. is not in the cert\'s altnames: ' +
3062
'DNS:omg.com'
3163
},
64+
{
65+
host: 'b.a.com',
66+
cert: { subject: { CN: 'b*b.a.com' } },
67+
error: 'Host: b.a.com. is not cert\'s CN: b*b.a.com'
68+
},
3269

3370
// Empty Cert
3471
{
@@ -199,6 +236,20 @@ var tests = [
199236
error: 'Host: localhost. is not in the cert\'s altnames: ' +
200237
'DNS:a.com'
201238
},
239+
// IDNA
240+
{
241+
host: 'xn--bcher-kva.example.com',
242+
cert: { subject: { CN: '*.example.com' } },
243+
},
244+
// RFC 6125, section 6.4.3: "[...] the client SHOULD NOT attempt to match
245+
// a presented identifier where the wildcard character is embedded within
246+
// an A-label [...]"
247+
{
248+
host: 'xn--bcher-kva.example.com',
249+
cert: { subject: { CN: 'xn--*.example.com' } },
250+
error: 'Host: xn--bcher-kva.example.com. is not cert\'s CN: ' +
251+
'xn--*.example.com',
252+
},
202253
];
203254

204255
tests.forEach(function(test, i) {

0 commit comments

Comments
 (0)