Skip to content

Commit 9dbde2f

Browse files
bnoordhuisrvagg
authored andcommitted
lib: make tls.checkServerIdentity() more strict
CVE-2016-7099 PR-URL: nodejs-private/node-private#61 Reviewed-By: Rod Vagg <rod@vagg.org>
1 parent 6d97790 commit 9dbde2f

File tree

2 files changed

+187
-113
lines changed

2 files changed

+187
-113
lines changed

lib/tls.js

+129-113
Original file line numberDiff line numberDiff line change
@@ -78,138 +78,154 @@ exports.convertNPNProtocols = function convertNPNProtocols(NPNProtocols, out) {
7878
}
7979
};
8080

81-
exports.checkServerIdentity = function checkServerIdentity(host, cert) {
82-
// Create regexp to much hostnames
83-
function regexpify(host, wildcards) {
84-
// Add trailing dot (make hostnames uniform)
85-
if (!/\.$/.test(host)) host += '.';
86-
87-
// The same applies to hostname with more than one wildcard,
88-
// if hostname has wildcard when wildcards are not allowed,
89-
// or if there are less than two dots after wildcard (i.e. *.com or *d.com)
90-
//
91-
// also
92-
//
93-
// "The client SHOULD NOT attempt to match a presented identifier in
94-
// which the wildcard character comprises a label other than the
95-
// left-most label (e.g., do not match bar.*.example.net)."
96-
// RFC6125
97-
if (!wildcards && /\*/.test(host) || /[\.\*].*\*/.test(host) ||
98-
/\*/.test(host) && !/\*.*\..+\..+/.test(host)) {
99-
return /$./;
100-
}
81+
function unfqdn(host) {
82+
return host.replace(/[.]$/, '');
83+
}
84+
85+
function splitHost(host) {
86+
// String#toLowerCase() is locale-sensitive so we use
87+
// a conservative version that only lowercases A-Z.
88+
function replacer(c) {
89+
return String.fromCharCode(32 + c.charCodeAt(0));
90+
};
91+
return unfqdn(host).replace(/[A-Z]/g, replacer).split('.');
92+
}
93+
94+
function check(hostParts, pattern, wildcards) {
95+
// Empty strings, null, undefined, etc. never match.
96+
if (!pattern)
97+
return false;
98+
99+
var patternParts = splitHost(pattern);
101100

102-
// Replace wildcard chars with regexp's wildcard and
103-
// escape all characters that have special meaning in regexps
104-
// (i.e. '.', '[', '{', '*', and others)
105-
var re = host.replace(
106-
/\*([a-z0-9\\-_\.])|[\.,\-\\\^\$+?*\[\]\(\):!\|{}]/g,
107-
function(all, sub) {
108-
if (sub) return '[a-z0-9\\-_]*' + (sub === '-' ? '\\-' : sub);
109-
return '\\' + all;
110-
});
111-
112-
return new RegExp('^' + re + '$', 'i');
101+
if (hostParts.length !== patternParts.length)
102+
return false;
103+
104+
// Pattern has empty components, e.g. "bad..example.com".
105+
if (patternParts.indexOf('') !== -1)
106+
return false;
107+
108+
// RFC 6125 allows IDNA U-labels (Unicode) in names but we have no
109+
// good way to detect their encoding or normalize them so we simply
110+
// reject them. Control characters and blanks are rejected as well
111+
// because nothing good can come from accepting them.
112+
function isBad(s) {
113+
return /[^\u0021-\u007F]/.test(s);
113114
}
114115

115-
var dnsNames = [],
116-
uriNames = [],
117-
ips = [],
118-
matchCN = true,
119-
valid = false,
120-
reason = 'Unknown reason';
121-
122-
// There're several names to perform check against:
123-
// CN and altnames in certificate extension
124-
// (DNS names, IP addresses, and URIs)
125-
//
126-
// Walk through altnames and generate lists of those names
127-
if (cert.subjectaltname) {
128-
cert.subjectaltname.split(/, /g).forEach(function(altname) {
129-
var option = altname.match(/^(DNS|IP Address|URI):(.*)$/);
130-
if (!option)
131-
return;
132-
if (option[1] === 'DNS') {
133-
dnsNames.push(option[2]);
134-
} else if (option[1] === 'IP Address') {
135-
ips.push(option[2]);
136-
} else if (option[1] === 'URI') {
137-
var uri = url.parse(option[2]);
138-
if (uri) uriNames.push(uri.hostname);
116+
if (patternParts.some(isBad))
117+
return false;
118+
119+
// Check host parts from right to left first.
120+
for (var i = hostParts.length - 1; i > 0; i -= 1)
121+
if (hostParts[i] !== patternParts[i])
122+
return false;
123+
124+
var hostSubdomain = hostParts[0];
125+
var patternSubdomain = patternParts[0];
126+
var patternSubdomainParts = patternSubdomain.split('*');
127+
128+
// Short-circuit when the subdomain does not contain a wildcard.
129+
// RFC 6125 does not allow wildcard substitution for components
130+
// containing IDNA A-labels (Punycode) so match those verbatim.
131+
if (patternSubdomainParts.length === 1 ||
132+
patternSubdomain.indexOf('xn--') !== -1) {
133+
return hostSubdomain === patternSubdomain;
134+
}
135+
136+
if (!wildcards)
137+
return false;
138+
139+
// More than one wildcard is always wrong.
140+
if (patternSubdomainParts.length > 2)
141+
return false;
142+
143+
// *.tld wildcards are not allowed.
144+
if (patternParts.length <= 2)
145+
return false;
146+
147+
var prefix = patternSubdomainParts[0];
148+
var suffix = patternSubdomainParts[1];
149+
150+
if (prefix.length + suffix.length > hostSubdomain.length)
151+
return false;
152+
153+
if (prefix.length > 0 && hostSubdomain.slice(0, prefix.length) !== prefix)
154+
return false;
155+
156+
if (suffix.length > 0 && hostSubdomain.slice(-suffix.length) !== suffix)
157+
return false;
158+
159+
return true;
160+
}
161+
162+
exports.checkServerIdentity = function checkServerIdentity(host, cert) {
163+
var subject = cert.subject;
164+
var altNames = cert.subjectaltname;
165+
var dnsNames = [];
166+
var uriNames = [];
167+
var ips = [];
168+
169+
host = '' + host;
170+
171+
if (altNames) {
172+
altNames.split(', ').forEach(function(name) {
173+
if (/^DNS:/.test(name)) {
174+
dnsNames.push(name.slice(4));
175+
} else if (/^URI:/.test(name)) {
176+
var uri = url.parse(name.slice(4));
177+
uriNames.push(uri.hostname); // TODO(bnoordhuis) Also use scheme.
178+
} else if (/^IP Address:/.test(name)) {
179+
ips.push(name.slice(11));
139180
}
140181
});
141182
}
142183

143-
// If hostname is an IP address, it should be present in the list of IP
144-
// addresses.
145-
if (net.isIP(host)) {
146-
valid = ips.some(function(ip) {
147-
return ip === host;
148-
});
149-
if (!valid) {
150-
reason = util.format('IP: %s is not in the cert\'s list: %s',
151-
host,
152-
ips.join(', '));
153-
}
154-
} else {
155-
// Transform hostname to canonical form
156-
if (!/\.$/.test(host)) host += '.';
184+
var valid = false;
185+
var reason = 'Unknown reason';
157186

158-
// Otherwise check all DNS/URI records from certificate
159-
// (with allowed wildcards)
160-
dnsNames = dnsNames.map(function(name) {
161-
return regexpify(name, true);
162-
});
187+
if (net.isIP(host)) {
188+
valid = ips.indexOf(host) !== -1;
189+
if (!valid)
190+
reason = 'IP: ' + host + ' is not in the cert\'s list: ' + ips.join(', ');
191+
// TODO(bnoordhuis) Also check URI SANs that are IP addresses.
192+
} else if (subject) {
193+
host = unfqdn(host); // Remove trailing dot for error messages.
194+
var hostParts = splitHost(host);
163195

164-
// Wildcards ain't allowed in URI names
165-
uriNames = uriNames.map(function(name) {
166-
return regexpify(name, false);
167-
});
196+
function wildcard(pattern) {
197+
return check(hostParts, pattern, true);
198+
}
168199

169-
dnsNames = dnsNames.concat(uriNames);
170-
171-
if (dnsNames.length > 0) matchCN = false;
172-
173-
// Match against Common Name (CN) only if no supported identifiers are
174-
// present.
175-
//
176-
// "As noted, a client MUST NOT seek a match for a reference identifier
177-
// of CN-ID if the presented identifiers include a DNS-ID, SRV-ID,
178-
// URI-ID, or any application-specific identifier types supported by the
179-
// client."
180-
// RFC6125
181-
if (matchCN) {
182-
var commonNames = cert.subject.CN;
183-
if (util.isArray(commonNames)) {
184-
for (var i = 0, k = commonNames.length; i < k; ++i) {
185-
dnsNames.push(regexpify(commonNames[i], true));
186-
}
187-
} else {
188-
dnsNames.push(regexpify(commonNames, true));
189-
}
200+
function noWildcard(pattern) {
201+
return check(hostParts, pattern, false);
190202
}
191203

192-
valid = dnsNames.some(function(re) {
193-
return re.test(host);
194-
});
204+
// Match against Common Name only if no supported identifiers are present.
205+
if (dnsNames.length === 0 && ips.length === 0 && uriNames.length === 0) {
206+
var cn = subject.CN;
195207

196-
if (!valid) {
197-
if (cert.subjectaltname) {
198-
reason = util.format('Host: %s is not in the cert\'s altnames: %s',
199-
host,
200-
cert.subjectaltname);
201-
} else {
202-
reason = util.format('Host: %s is not cert\'s CN: %s',
203-
host,
204-
cert.subject.CN);
208+
if (Array.isArray(cn))
209+
valid = cn.some(wildcard);
210+
else if (cn)
211+
valid = wildcard(cn);
212+
213+
if (!valid)
214+
reason = 'Host: ' + host + '. is not cert\'s CN: ' + cn;
215+
} else {
216+
valid = dnsNames.some(wildcard) || uriNames.some(noWildcard);
217+
if (!valid) {
218+
reason =
219+
'Host: ' + host + '. is not in the cert\'s altnames: ' + altNames;
205220
}
206221
}
222+
} else {
223+
reason = 'Cert is empty';
207224
}
208225

209226
if (!valid) {
210227
var err = new Error(
211-
util.format('Hostname/IP doesn\'t match certificate\'s altnames: %j',
212-
reason));
228+
'Hostname/IP doesn\'t match certificate\'s altnames: "' + reason + '"');
213229
err.reason = reason;
214230
err.host = host;
215231
err.cert = cert;

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

+58
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,23 @@ var util = require('util');
2525
var tls = require('tls');
2626

2727
var tests = [
28+
// False-y values.
29+
{
30+
host: false,
31+
cert: { subject: { CN: 'a.com' } },
32+
error: 'Host: false. is not cert\'s CN: a.com'
33+
},
34+
{
35+
host: null,
36+
cert: { subject: { CN: 'a.com' } },
37+
error: 'Host: null. is not cert\'s CN: a.com'
38+
},
39+
{
40+
host: undefined,
41+
cert: { subject: { CN: 'a.com' } },
42+
error: 'Host: undefined. is not cert\'s CN: a.com'
43+
},
44+
2845
// Basic CN handling
2946
{ host: 'a.com', cert: { subject: { CN: 'a.com' } } },
3047
{ host: 'a.com', cert: { subject: { CN: 'A.COM' } } },
@@ -34,15 +51,42 @@ var tests = [
3451
error: 'Host: a.com. is not cert\'s CN: b.com'
3552
},
3653
{ host: 'a.com', cert: { subject: { CN: 'a.com.' } } },
54+
{
55+
host: 'a.com',
56+
cert: { subject: { CN: '.a.com' } },
57+
error: 'Host: a.com. is not cert\'s CN: .a.com'
58+
},
3759

3860
// Wildcards in CN
3961
{ host: 'b.a.com', cert: { subject: { CN: '*.a.com' } } },
62+
{
63+
host: 'ba.com',
64+
cert: { subject: { CN: '*.a.com' } },
65+
error: 'Host: ba.com. is not cert\'s CN: *.a.com'
66+
},
67+
{
68+
host: '\n.b.com',
69+
cert: { subject: { CN: '*n.b.com' } },
70+
error: 'Host: \n.b.com. is not cert\'s CN: *n.b.com'
71+
},
4072
{ host: 'b.a.com', cert: {
4173
subjectaltname: 'DNS:omg.com',
4274
subject: { CN: '*.a.com' } },
4375
error: 'Host: b.a.com. is not in the cert\'s altnames: ' +
4476
'DNS:omg.com'
4577
},
78+
{
79+
host: 'b.a.com',
80+
cert: { subject: { CN: 'b*b.a.com' } },
81+
error: 'Host: b.a.com. is not cert\'s CN: b*b.a.com'
82+
},
83+
84+
// Empty Cert
85+
{
86+
host: 'a.com',
87+
cert: { },
88+
error: 'Cert is empty'
89+
},
4690

4791
// Multiple CN fields
4892
{
@@ -206,6 +250,20 @@ var tests = [
206250
error: 'Host: localhost. is not in the cert\'s altnames: ' +
207251
'DNS:a.com'
208252
},
253+
// IDNA
254+
{
255+
host: 'xn--bcher-kva.example.com',
256+
cert: { subject: { CN: '*.example.com' } },
257+
},
258+
// RFC 6125, section 6.4.3: "[...] the client SHOULD NOT attempt to match
259+
// a presented identifier where the wildcard character is embedded within
260+
// an A-label [...]"
261+
{
262+
host: 'xn--bcher-kva.example.com',
263+
cert: { subject: { CN: 'xn--*.example.com' } },
264+
error: 'Host: xn--bcher-kva.example.com. is not cert\'s CN: ' +
265+
'xn--*.example.com',
266+
},
209267
];
210268

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

0 commit comments

Comments
 (0)