Skip to content

Commit 24fa80e

Browse files
jpage-godaddyindexzero
authored andcommitted
Support SNI (#19)
Add support to easily add SNI to your HTTPS server.
1 parent df84eca commit 24fa80e

13 files changed

+406
-18
lines changed

README.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ a node-style callback. The config object must have at minimum an `http` or
2727
| `https.key` | PEM/file path for the server's private key. See [Certificate normalization](#certificate-normalization) for more details. |
2828
| `https.cert` | PEM/file path(s) for the server's certificate. See [Certificate normalization](#certificate-normalization) for more details. |
2929
| `https.ca` | Cert or array of certs specifying trusted authorities for peer certificates. Only required if your server accepts client certificate connections signed by authorities that are not trusted by default. See [Certificate normalization](#certificate-normalization) for more details. |
30+
| `https.sni` | See [SNI Support](#sni-support). |
3031
| `https.handler` | Handler for HTTPS requests. If you want to share a handler with all servers, use a top-level `handler` config property instead. |
3132
| `https.*` | Any other properties supported by [https.createServer](https://nodejs.org/dist/latest-v8.x/docs/api/https.html#https_https_createserver_options_requestlistener) can be added to the https object, except `secureProtocol` and `secureOptions` which are set to recommended values. |
3233

@@ -38,7 +39,7 @@ following properties:
3839
| `http` | The HTTP server that was created, if any |
3940
| `https` | The HTTPS server that was created, if any |
4041

41-
### Certificate normalization
42+
### Certificate Normalization
4243

4344
`create-servers` provides some conveniences for `https.ca`, `https.key`, and
4445
`https.cert` config properties. You may use PEM data directly (inside a `Buffer`
@@ -99,6 +100,61 @@ createServers({
99100
})
100101
```
101102

103+
### SNI Support
104+
105+
[Server Name Indication](https://en.wikipedia.org/wiki/Server_Name_Indication),
106+
or SNI, lets HTTPS clients announce which hostname they wish to connect to
107+
before the server sends its certificate, enabling the use of the same server for
108+
multiple hosts. Although `SNICallback` can be used to support this, you lose the
109+
convenient certificate normalization provided by `create-servers`. The `sni`
110+
config option provides an easier way.
111+
112+
The `sni` option is an object with each key being a supported hostname and each
113+
value being a subset of the HTTPS settings listed above. HTTPS settings defined
114+
at the top level are used as defaults for the hostname-specific settings.
115+
116+
```js
117+
const createServers = require('create-servers');
118+
119+
createServers(
120+
{
121+
https: {
122+
port: 443,
123+
sni: {
124+
'example1.com': {
125+
key: '/certs/private/example1.com.key',
126+
cert: '/certs/public/example1.com.crt'
127+
},
128+
'example2.com': {
129+
key: '/certs/private/example2.com.key',
130+
cert: '/certs/public/example2.com.crt'
131+
}
132+
}
133+
},
134+
handler: function (req, res) {
135+
res.end('Hello');
136+
}
137+
},
138+
function (errs) {
139+
if (errs) {
140+
return console.log(errs.https);
141+
}
142+
143+
console.log('Listening on 443');
144+
}
145+
);
146+
```
147+
148+
Use `*` in the hostname for wildcard certs. Example: `*.example.com`. The
149+
following settings are supported in the host-specific configuration:
150+
151+
* key
152+
* cert
153+
* ca
154+
* ciphers
155+
* honorCipherOrder
156+
* Anything else supported by [`tls.createSecureContext`](https://nodejs.org/dist/latest-v8.x/docs/api/tls.html#tls_tls_createsecurecontext_options)
157+
102158
## NOTE on Security
103159
Inspired by [`iojs`][iojs] and a well written [article][article], we have defaulted
104160
our [ciphers][ciphers] to support "perfect-forward-security" as well as removing insecure

index.js

Lines changed: 75 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
var fs = require('fs'),
1111
http = require('http'),
1212
https = require('https'),
13+
tls = require('tls'),
1314
path = require('path'),
15+
constants = require('constants'),
1416
connected = require('connected'),
1517
errs = require('errs'),
1618
assign = require('object-assign');
@@ -36,6 +38,8 @@ var CIPHERS = [
3638
'!CAMELLIA'
3739
].join(':');
3840

41+
var secureOptions = constants.SSL_OP_NO_SSLv3;
42+
3943
/**
4044
* function createServers (dispatch, options, callback)
4145
* Creates and listens on both HTTP and HTTPS servers.
@@ -120,52 +124,43 @@ module.exports = function createServers(options, listening) {
120124
// ### function createHttps ()
121125
// Attempts to create and listen on the HTTPS server.
122126
//
123-
function createHttps(next) {
127+
function createHttps() {
124128
if (typeof options.https === 'undefined') {
125129
log('https | no options.https; no server');
126130
return onListen('https');
127131
}
128132

129133
var ssl = options.https,
130134
port = !isNaN(ssl.port) ? +ssl.port : 443, // accepts string or number
131-
ciphers = ssl.ciphers || CIPHERS,
132135
timeout = options.timeout || ssl.timeout,
133-
ca = ssl.ca,
134136
server,
135137
args;
136138

137-
//
138-
// Remark: If an array is passed in lets join it like we do the defaults
139-
//
140-
if (Array.isArray(ciphers)) {
141-
ciphers = ciphers.join(':');
142-
}
143-
144-
if (ca && !Array.isArray(ca)) {
145-
ca = [ca];
146-
}
147-
148139
var finalHttpsOptions = assign({}, ssl, {
149140
//
150141
// Load default SSL key, cert and ca(s).
151142
//
152143
key: normalizePEMContent(ssl.root, ssl.key),
153144
cert: normalizeCertContent(ssl.root, ssl.cert, ssl.key),
154-
ca: ca && ca.map(normalizePEMContent.bind(null, ssl.root)),
145+
ca: normalizeCA(ssl.root, ssl.ca),
155146
//
156147
// Properly expose ciphers for an A+ SSL rating:
157148
// https://certsimple.com/blog/a-plus-node-js-ssl
158149
//
159-
ciphers: ciphers,
150+
ciphers: normalizeCiphers(ssl.ciphers),
160151
honorCipherOrder: !!ssl.honorCipherOrder,
161152
//
162153
// Protect against the POODLE attack by disabling SSLv3
163154
// @see http://googleonlinesecurity.blogspot.nl/2014/10/this-poodle-bites-exploiting-ssl-30.html
164155
//
165156
secureProtocol: 'SSLv23_method',
166-
secureOptions: require('constants').SSL_OP_NO_SSLv3
157+
secureOptions: secureOptions
167158
});
168159

160+
if (ssl.sni && !finalHttpsOptions.SNICallback) {
161+
finalHttpsOptions.SNICallback = getSNIHandler(ssl)
162+
}
163+
169164
log('https | listening on %d', port);
170165
server = https.createServer(finalHttpsOptions, ssl.handler || handler);
171166

@@ -219,6 +214,13 @@ function normalizeCertChain(root, data) {
219214
return Array.isArray(content) ? content.join('\n') : content;
220215
}
221216

217+
function normalizeCA(root, ca) {
218+
if (ca && !Array.isArray(ca)) {
219+
ca = [ca];
220+
}
221+
return ca && ca.map(normalizePEMContent.bind(null, root));
222+
}
223+
222224
/**
223225
* function normalizePEMContent(root, file)
224226
* Returns the contents of `file` verbatim if it is determined to be
@@ -239,3 +241,59 @@ function normalizePEMContent(root, file) {
239241

240242
return fs.readFileSync(path.resolve(root, file));
241243
}
244+
245+
function normalizeCiphers(ciphers) {
246+
ciphers = ciphers || CIPHERS;
247+
//
248+
// Remark: If an array is passed in lets join it like we do the defaults
249+
//
250+
if (Array.isArray(ciphers)) {
251+
ciphers = ciphers.join(':');
252+
}
253+
return ciphers;
254+
}
255+
256+
function getSNIHandler(sslOpts) {
257+
var sniHosts = Object.keys(sslOpts.sni);
258+
259+
// Pre-compile regexps for the hostname
260+
var hostRegexps = sniHosts.map(function (host) {
261+
return new RegExp(
262+
'^' +
263+
host
264+
.replace('.', '\\.') // Match dots, not wildcards
265+
.replace('*\\.', '(?:.*\\.)?') + // Handle optional wildcard sub-domains
266+
'$',
267+
'i'
268+
);
269+
});
270+
271+
// Prepare secure context params ahead-of-time
272+
var hostTlsOpts = sniHosts.map(function (host) {
273+
var hostOpts = sslOpts.sni[host];
274+
275+
var root = hostOpts.root || sslOpts.root;
276+
277+
return assign({}, sslOpts, hostOpts, {
278+
key: normalizePEMContent(root, hostOpts.key),
279+
cert: normalizeCertContent(root, hostOpts.cert),
280+
ca: normalizeCA(root, hostOpts.ca || sslOpts.ca),
281+
ciphers: normalizeCiphers(hostOpts.ciphers || sslOpts.ciphers),
282+
honorCipherOrder: !!(hostOpts.honorCipherOrder || sslOpts.honorCipherOrder),
283+
secureProtocol: 'SSLv23_method',
284+
secureOptions: secureOptions
285+
});
286+
});
287+
288+
return function (hostname, cb) {
289+
var matchingHostIdx = sniHosts.findIndex(function(candidate, i) {
290+
return hostRegexps[i].test(hostname);
291+
});
292+
293+
if (matchingHostIdx === -1) {
294+
return void cb(new Error('Unrecognized hostname: ' + hostname));
295+
}
296+
297+
cb(null, tls.createSecureContext(hostTlsOpts[matchingHostIdx]));
298+
};
299+
}

package-lock.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"object-assign": "^4.1.0"
2828
},
2929
"devDependencies": {
30+
"evil-dns": "^0.2.0",
3031
"sinon": "^5.0.7",
3132
"tape": "~4.9.0"
3233
}

test/create-servers-test.js

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,49 @@
77

88
var path = require('path'),
99
fs = require('fs'),
10+
url = require('url'),
1011
http = require('http'),
1112
https = require('https'),
13+
{ promisify } = require('util'),
1214
test = require('tape'),
1315
sinon = require('sinon'),
16+
evilDNS = require('evil-dns'),
1417
createServers = require('../');
1518

19+
const createServersAsync = promisify(createServers);
20+
21+
const ca = fs.readFileSync(path.join(__dirname, './fixtures/example-ca-cert.pem'));
22+
1623
//
1724
// Immediately end a response.
1825
//
1926
function fend(req, res) {
2027
res.end();
2128
}
2229

30+
//
31+
// Request and download response from a URL
32+
//
33+
async function download(httpsURL) {
34+
return new Promise((resolve, reject) => {
35+
const req = https.get({
36+
...url.parse(httpsURL),
37+
ca
38+
}, res => {
39+
const chunks = [];
40+
res
41+
.on('data', chunk => chunks.push(chunk))
42+
.once('end', () => {
43+
resolve(chunks.map(chunk => chunk.toString('utf8')).join(''));
44+
})
45+
.once('aborted', reject)
46+
.once('close', reject)
47+
.once('error', reject)
48+
});
49+
req.once('error', reject);
50+
});
51+
}
52+
2353
test('only http', function (t) {
2454
t.plan(3);
2555
createServers({
@@ -321,3 +351,58 @@ test('supports requestCert https option', function (t) {
321351
spy.restore();
322352
});
323353
});
354+
355+
test('supports SNI', async t => {
356+
t.plan(1);
357+
358+
const hostNames = [
359+
'example.com',
360+
'example.net',
361+
'foo.example.org',
362+
];
363+
364+
let httpsServer;
365+
try {
366+
const servers = await createServersAsync({
367+
https: {
368+
port: 3456,
369+
root: path.join(__dirname, 'fixtures'),
370+
sni: {
371+
'example.com': {
372+
key: 'example-com-key.pem',
373+
cert: 'example-com-cert.pem'
374+
},
375+
'example.net': {
376+
key: 'example-net-key.pem',
377+
cert: 'example-net-cert.pem'
378+
},
379+
'*.example.org': {
380+
key: 'example-org-key.pem',
381+
cert: 'example-org-cert.pem'
382+
}
383+
}
384+
},
385+
handler: (req, res) => {
386+
res.write('Hello');
387+
res.end();
388+
}
389+
});
390+
httpsServer = servers.https;
391+
392+
hostNames.forEach(host => evilDNS.add(host, '0.0.0.0'));
393+
394+
const responses = await Promise.all(hostNames
395+
.map(hostname => download(`https://${hostname}:3456/`)));
396+
397+
t.equals(
398+
responses.every(str => str === 'Hello'),
399+
true,
400+
'responses are as expected');
401+
402+
} catch (err) {
403+
return void t.error(err);
404+
} finally {
405+
httpsServer && httpsServer.close();
406+
evilDNS.clear();
407+
}
408+
});

test/fixtures/example-ca-cert.pem

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-----BEGIN CERTIFICATE-----
2+
MIIDAjCCAeoCCQDYRRRzyGC6DTANBgkqhkiG9w0BAQsFADBDMQswCQYDVQQGEwJV
3+
UzEVMBMGA1UECgwMRXhhbXBsZSwgTExDMR0wGwYDVQQDDBRFeGFtcGxlLCBMTEMg
4+
Um9vdCBDQTAeFw0xODA3MTYyMjA0MjNaFw0zMjAzMjQyMjA0MjNaMEMxCzAJBgNV
5+
BAYTAlVTMRUwEwYDVQQKDAxFeGFtcGxlLCBMTEMxHTAbBgNVBAMMFEV4YW1wbGUs
6+
IExMQyBSb290IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA26Od
7+
pa6GI2pdRHPgNJzMJdgs9ZQ7i9g4/U3n15xifAGNsGXQc7dgfe22fCdJ4YJBdCHK
8+
ikPQF5thS1iu4Sn03yMHj8QapPThAsgI5Xe43eoFqpCghsD27J3efsv+gncrxW8q
9+
i37o3vSmaom5gUTjY42l0WP0bN/Ehkeptz5TlhbKEfSq30oZFbh68BnTKU1p3ai6
10+
j7iHnervoHDlAWzI0QUTn/DufDyMOT2+AUUso6ElZsk+aqlmE7ZcYmVjwKKd82ZC
11+
k16tJQAyKvZSosQWPokH5uKSV64GJHJElE6l1GzNxrIAGocLeowF8fcZUvcNSXO9
12+
ZUYWccipgOalYyJyIwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQAkLI/zlkuR0Suj
13+
/mWlAnxQee38bAA03phidFpVczoCM/EP9TtekK90ML0BGK49EvwyD0MJQh832G6s
14+
JKFL4rQFW7PfCyejIxyrn+p2tIfArEYbmxoLlfkWiBTT+y4Cpq3q72z8WDVRuMTR
15+
qaIClkyJ5RcNWOTV0rZFKrqm4Hu+O+3LWwxk6eoGb7M6cUqCSWnw71zX71SwnGuL
16+
5pzEbUaovJN7Q2TCnqiBxcGF0ukhovWnN37ZT41OPi1Fz6sCG5BDDxEaB3xAWY9k
17+
15uhxhqwRdYB7fDBuY51F02uUrA9weDFvN0kqfNwn0HRi6ptCqA0kuIBIqMqWhUz
18+
7o1jaXrK
19+
-----END CERTIFICATE-----

0 commit comments

Comments
 (0)