tls: veryify server's identity
authorFedor Indutny <fedor.indutny@gmail.com>
Wed, 11 Jul 2012 19:54:20 +0000 (23:54 +0400)
committerFedor Indutny <fedor.indutny@gmail.com>
Thu, 19 Jul 2012 21:49:31 +0000 (01:49 +0400)
lib/http.js
lib/tls.js
src/node_crypto.cc
test/simple/test-tls-check-server-identity.js [new file with mode: 0644]

index eccea994483019b052c6296717927b62e4a58ae4..5e7c0ba16b59624bf4fdec89d011b9955324434c 100644 (file)
@@ -1123,13 +1123,11 @@ Agent.prototype.removeSocket = function(s, name, host, port, localAddress) {
   }
   if (this.requests[name] && this.requests[name].length) {
     // If we have pending requests and a socket gets closed a new one
-    this.createSocket(
-      name,
-      host,
-      port,
-      localAddress,
-      this.requests[name][0]
-    ).emit('free');
+    this.createSocket(name,
+                      host,
+                      port,
+                      localAddress,
+                      this.requests[name][0]).emit('free');
   }
 };
 
@@ -1141,7 +1139,7 @@ function ClientRequest(options, cb) {
   var self = this;
   OutgoingMessage.call(self);
 
-  this.options = options;
+  this.options = util._extend({}, options);
   self.agent = options.agent === undefined ? globalAgent : options.agent;
 
   var defaultPort = options.defaultPort || 80;
index 9f3a42b5117f1fd4c69a2a9d765d6983221f5140..640328ec4fa897fb47bbf051786e465abaa69743 100644 (file)
@@ -22,6 +22,7 @@
 var crypto = require('crypto');
 var util = require('util');
 var net = require('net');
+var url = require('url');
 var events = require('events');
 var Stream = require('stream');
 var END_OF_FILE = 42;
@@ -77,6 +78,99 @@ function convertNPNProtocols(NPNProtocols, out) {
   }
 }
 
+
+function checkServerIdentity(host, cert) {
+  // Create regexp to much hostnames
+  function regexpify(host, wildcards) {
+    // Add trailing dot (make hostnames uniform)
+    if (!/\.$/.test(host)) host += '.';
+
+    // Host names with less than one dots are considered too broad,
+    // and should not be allowed
+    if (!/^.+\..+$/.test(host)) return /$./;
+
+    // The same applies to hostname with more than one wildcard,
+    // if hostname has wildcard when wildcards are not allowed,
+    // or if there are less than two dots after wildcard (i.e. *.com or *d.com)
+    if (/\*.*\*/.test(host) || !wildcards && /\*/.test(host) ||
+        /\*/.test(host) && !/\*.*\..+\..+/.test(host)) {
+      return /$./;
+    }
+
+    // Replace wildcard chars with regexp's wildcard and
+    // escape all characters that have special meaning in regexps
+    // (i.e. '.', '[', '{', '*', and others)
+    var re = host.replace(
+      /\*([a-z0-9\\-_\.])|[\.,\-\\\^\$+?*\[\]\(\):!\|{}]/g,
+      function(all, sub) {
+        if (sub) return '[a-z0-9\\-_]*' + (sub === '-' ? '\\-' : sub);
+        return '\\' + all;
+      }
+    );
+
+    return new RegExp('^' + re + '$', 'i');
+  }
+
+  var dnsNames = [],
+      uriNames = [],
+      ips = [],
+      valid = false;
+
+  // There're several names to perform check against:
+  // CN and altnames in certificate extension
+  // (DNS names, IP addresses, and URIs)
+  //
+  // Walk through altnames and generate lists of those names
+  if (cert.subjectaltname) {
+    cert.subjectaltname.split(/, /g).forEach(function(altname) {
+      if (/^DNS:/.test(altname)) {
+        dnsNames.push(altname.slice(4));
+      } else if (/^IP Address:/.test(altname)) {
+        ips.push(altname.slice(11));
+      } else if (/^URI:/.test(altname)) {
+        var uri = url.parse(altname.slice(4));
+        if (uri) uriNames.push(uri.hostname);
+      }
+    });
+  }
+
+  // If hostname is an IP address, it should be present in the list of IP
+  // addresses.
+  if (net.isIP(host)) {
+    valid = ips.some(function(ip) {
+      return ip === host;
+    });
+  } else {
+    // Transform hostname to canonical form
+    if (!/\.$/.test(host)) host += '.';
+
+    // Otherwise check all DNS/URI records from certificate
+    // (with allowed wildcards)
+    dnsNames = dnsNames.map(function(name) {
+      return regexpify(name, true);
+    });
+
+    // Wildcards ain't allowed in URI names
+    uriNames = uriNames.map(function(name) {
+      return regexpify(name, false);
+    });
+
+    dnsNames = dnsNames.concat(uriNames);
+
+    // And only after check if hostname matches CN
+    // (because CN is deprecated, but should be used for compatiblity anyway)
+    dnsNames.push(regexpify(cert.subject.CN, false));
+
+    valid = dnsNames.some(function(re) {
+      return re.test(host);
+    });
+  }
+
+  return valid;
+};
+exports.checkServerIdentity = checkServerIdentity;
+
+
 // Base class of both CleartextStream and EncryptedStream
 function CryptoStream(pair) {
   Stream.call(this);
@@ -1118,11 +1212,12 @@ exports.connect = function(/* [port, host], options, cb */) {
   var sslcontext = crypto.createCredentials(options);
 
   convertNPNProtocols(options.NPNProtocols, this);
-  var pair = new SecurePair(sslcontext, false, true,
+  var hostname = options.servername || options.host,
+      pair = new SecurePair(sslcontext, false, true,
                             options.rejectUnauthorized === true ? true : false,
                             {
                               NPNProtocols: this.NPNProtocols,
-                              servername: options.servername || options.host
+                              servername: hostname
                             });
 
   if (options.session) {
@@ -1147,9 +1242,19 @@ exports.connect = function(/* [port, host], options, cb */) {
 
     cleartext.npnProtocol = pair.npnProtocol;
 
+    // Verify that server's identity matches it's certificate's names
+    if (!verifyError) {
+      var validCert = checkServerIdentity(hostname,
+                                          pair.cleartext.getPeerCertificate());
+      if (!validCert) {
+        verifyError = new Error('Hostname/IP doesn\'t match certificate\'s ' +
+                                'altnames');
+      }
+    }
+
     if (verifyError) {
       cleartext.authorized = false;
-      cleartext.authorizationError = verifyError;
+      cleartext.authorizationError = verifyError.message;
 
       if (pair._rejectUnauthorized) {
         cleartext.emit('error', verifyError);
index 8b21825a2bf572caa8b08fba2f869241a63bc425..5b21e55a8cf2100ffd57bd8afefcf3903dff57f5 100644 (file)
@@ -1547,7 +1547,8 @@ Handle<Value> Connection::VerifyError(const Arguments& args) {
     // We requested a certificate and they did not send us one.
     // Definitely an error.
     // XXX is this the right error message?
-    return scope.Close(String::New("UNABLE_TO_GET_ISSUER_CERT"));
+    return scope.Close(Exception::Error(
+          String::New("UNABLE_TO_GET_ISSUER_CERT")));
   }
   X509_free(peer_cert);
 
@@ -1673,7 +1674,7 @@ Handle<Value> Connection::VerifyError(const Arguments& args) {
       break;
   }
 
-  return scope.Close(s);
+  return scope.Close(Exception::Error(s));
 }
 
 
diff --git a/test/simple/test-tls-check-server-identity.js b/test/simple/test-tls-check-server-identity.js
new file mode 100644 (file)
index 0000000..f79823b
--- /dev/null
@@ -0,0 +1,189 @@
+// Copyright Joyent, Inc. and other Node contributors.
+//
+// Permission is hereby granted, free of charge, to any person obtaining a
+// copy of this software and associated documentation files (the
+// "Software"), to deal in the Software without restriction, including
+// without limitation the rights to use, copy, modify, merge, publish,
+// distribute, sublicense, and/or sell copies of the Software, and to permit
+// persons to whom the Software is furnished to do so, subject to the
+// following conditions:
+//
+// The above copyright notice and this permission notice shall be included
+// in all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
+// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
+// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
+// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+// USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+var common = require('../common');
+var assert = require('assert');
+var util = require('util');
+var tls = require('tls');
+
+var tests = [
+  // Basic CN handling
+  { host: 'a.com', cert: { subject: { CN: 'a.com' } }, result: true },
+  { host: 'a.com', cert: { subject: { CN: 'A.COM' } }, result: true },
+  { host: 'a.com', cert: { subject: { CN: 'b.com' } }, result: false },
+  { host: 'a.com', cert: { subject: { CN: 'a.com.' } }, result: true },
+
+  // No wildcards in CN
+  { host: 'b.a.com', cert: { subject: { CN: '*.a.com' } }, result: false },
+
+  // DNS names and CN
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:*',
+      subject: { CN: 'b.com' }
+    },
+    result: false
+  },
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:*.com',
+      subject: { CN: 'b.com' }
+    },
+    result: false
+  },
+  {
+    host: 'a.co.uk', cert: {
+      subjectaltname: 'DNS:*.co.uk',
+      subject: { CN: 'b.com' }
+    },
+    result: true
+  },
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:*.a.com',
+      subject: { CN: 'a.com' }
+    },
+    result: true
+  },
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:*.a.com',
+      subject: { CN: 'b.com' }
+    },
+    result: false
+  },
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:a.com',
+      subject: { CN: 'b.com' }
+    },
+    result: true
+  },
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:A.COM',
+      subject: { CN: 'b.com' }
+    },
+    result: true
+  },
+
+  // DNS names
+  {
+    host: 'a.com', cert: {
+      subjectaltname: 'DNS:*.a.com',
+      subject: {}
+    },
+    result: false
+  },
+  {
+    host: 'b.a.com', cert: {
+      subjectaltname: 'DNS:*.a.com',
+      subject: {}
+    },
+    result: true
+  },
+  {
+    host: 'c.b.a.com', cert: {
+      subjectaltname: 'DNS:*.a.com',
+      subject: {}
+    },
+    result: false
+  },
+  {
+    host: 'b.a.com', cert: {
+      subjectaltname: 'DNS:*b.a.com',
+      subject: {}
+    },
+    result: true
+  },
+  {
+    host: 'a-cb.a.com', cert: {
+      subjectaltname: 'DNS:*b.a.com',
+      subject: {}
+    },
+    result: true
+  },
+  {
+    host: 'a.b.a.com', cert: {
+      subjectaltname: 'DNS:*b.a.com',
+      subject: {}
+    },
+    result: false
+  },
+  // Mutliple DNS names
+  {
+    host: 'a.b.a.com', cert: {
+      subjectaltname: 'DNS:*b.a.com, DNS:a.b.a.com',
+      subject: {}
+    },
+    result: true
+  },
+  // URI names
+  {
+    host: 'a.b.a.com', cert: {
+      subjectaltname: 'URI:http://a.b.a.com/',
+      subject: {}
+    },
+    result: true
+  },
+  {
+    host: 'a.b.a.com', cert: {
+      subjectaltname: 'URI:http://*.b.a.com/',
+      subject: {}
+    },
+    result: false
+  },
+  // IP addresses
+  {
+    host: 'a.b.a.com', cert: {
+      subjectaltname: 'IP Address:127.0.0.1',
+      subject: {}
+    },
+    result: false
+  },
+  {
+    host: '127.0.0.1', cert: {
+      subjectaltname: 'IP Address:127.0.0.1',
+      subject: {}
+    },
+    result: true
+  },
+  {
+    host: '127.0.0.2', cert: {
+      subjectaltname: 'IP Address:127.0.0.1',
+      subject: {}
+    },
+    result: false
+  },
+  {
+    host: '127.0.0.1', cert: {
+      subjectaltname: 'DNS:a.com',
+      subject: {}
+    },
+    result: false
+  },
+];
+
+tests.forEach(function(test, i) {
+  assert.equal(tls.checkServerIdentity(test.host, test.cert),
+               test.result,
+               'Test#' + i + ' failed: ' + util.inspect(test));
+});