From 8d045a30e95602b443eb259a5021d33feb4df079 Mon Sep 17 00:00:00 2001 From: Julien Gilli Date: Wed, 22 Oct 2014 17:58:16 -0700 Subject: [PATCH] tests: add TLS tests matrix Add a test that goes through the whole matrix of: - command line options (--enable-ssl*) - secureOptions - secureProtocols and makes sure that compatible test setups actually work as expected. The test works by spawning two processes for each test case: one client and one server. The test passes if a SSL/TLS connection from the client to the server is successful and the test case was supposed to pass, or if the connection couldn't be established and the test case was supposed to fail. The test is currently located in the directory 'test/external' because it has external dependencies. --- test/external/ssl-options/.gitignore | 1 + test/external/ssl-options/package.json | 15 + test/external/ssl-options/test.js | 729 +++++++++++++++++++++++++ 3 files changed, 745 insertions(+) create mode 100644 test/external/ssl-options/.gitignore create mode 100644 test/external/ssl-options/package.json create mode 100644 test/external/ssl-options/test.js diff --git a/test/external/ssl-options/.gitignore b/test/external/ssl-options/.gitignore new file mode 100644 index 00000000000000..c2658d7d1b3184 --- /dev/null +++ b/test/external/ssl-options/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/test/external/ssl-options/package.json b/test/external/ssl-options/package.json new file mode 100644 index 00000000000000..114dce6afb425f --- /dev/null +++ b/test/external/ssl-options/package.json @@ -0,0 +1,15 @@ +{ + "name": "ssl-options-tests", + "version": "1.0.0", + "description": "", + "main": "test.js", + "scripts": { + "test": "node test.js" + }, + "author": "", + "license": "MIT", + "dependencies": { + "async": "^0.9.0", + "debug": "^2.1.0" + } +} diff --git a/test/external/ssl-options/test.js b/test/external/ssl-options/test.js new file mode 100644 index 00000000000000..f7e06c93dfa740 --- /dev/null +++ b/test/external/ssl-options/test.js @@ -0,0 +1,729 @@ +var tls = require('tls'); +var fs = require('fs'); +var path = require('path'); +var fork = require('child_process').fork; +var assert = require('assert'); +var constants = require('constants'); +var os = require('os'); + +var async = require('async'); +var debug = require('debug')('test-node-ssl'); + +var common = require('../../common'); + +var SSL2_COMPATIBLE_CIPHERS = 'RC4-MD5'; + +var CMD_LINE_OPTIONS = [ null, "--enable-ssl2", "--enable-ssl3" ]; + +var SERVER_SSL_PROTOCOLS = [ + null, + 'SSLv2_method', 'SSLv2_server_method', + 'SSLv3_method', 'SSLv3_server_method', + 'TLSv1_method', 'TLSv1_server_method', + 'SSLv23_method','SSLv23_server_method' +]; + +var CLIENT_SSL_PROTOCOLS = [ + null, + 'SSLv2_method', 'SSLv2_client_method', + 'SSLv3_method', 'SSLv3_client_method', + 'TLSv1_method', 'TLSv1_client_method', + 'SSLv23_method','SSLv23_client_method' +]; + +var SECURE_OPTIONS = [ + null, + 0, + constants.SSL_OP_NO_SSLv2, + constants.SSL_OP_NO_SSLv3, + constants.SSL_OP_NO_SSLv2 | constants.SSL_OP_NO_SSLv3 +]; + +function xtend(source) { + var clone = {}; + + for (var property in source) { + if (source.hasOwnProperty(property)) { + clone[property] = source[property]; + } + } + + return clone; +} + +function isAutoNegotiationProtocol(sslProtocol) { + assert(sslProtocol === null || typeof sslProtocol === 'string'); + + return sslProtocol == null || + sslProtocol === 'SSLv23_method' || + sslProtocol === 'SSLv23_client_method' || + sslProtocol === 'SSLv23_server_method'; +} + +function isSameSslProtocolVersion(serverSecureProtocol, clientSecureProtocol) { + assert(serverSecureProtocol === null || typeof serverSecureProtocol === 'string'); + assert(clientSecureProtocol === null || typeof clientSecureProtocol === 'string'); + + if (serverSecureProtocol === clientSecureProtocol) { + return true; + } + + var serverProtocolPrefix = ''; + if (serverSecureProtocol) + serverProtocolPrefix = serverSecureProtocol.split('_')[0]; + + var clientProtocolPrefix = ''; + if (clientSecureProtocol) + clientProtocolPrefix = clientSecureProtocol.split('_')[0]; + + if (serverProtocolPrefix === clientProtocolPrefix) { + return true; + } + + return false; +} + +function secureProtocolsCompatible(serverSecureProtocol, clientSecureProtocol) { + if (isAutoNegotiationProtocol(serverSecureProtocol) || + isAutoNegotiationProtocol(clientSecureProtocol)) { + return true; + } + + if (isSameSslProtocolVersion(serverSecureProtocol, + clientSecureProtocol)) { + return true; + } + + return false; +} + +function isSsl3Protocol(secureProtocol) { + assert(secureProtocol === null || typeof secureProtocol === 'string'); + + return secureProtocol === 'SSLv3_method' || + secureProtocol === 'SSLv3_client_method' || + secureProtocol === 'SSLv3_server_method'; +} + +function isSsl2Protocol(secureProtocol) { + assert(secureProtocol === null || typeof secureProtocol === 'string'); + + return secureProtocol === 'SSLv2_method' || + secureProtocol === 'SSLv2_client_method' || + secureProtocol === 'SSLv2_server_method'; +} + +function secureProtocolCompatibleWithSecureOptions(secureProtocol, secureOptions, cmdLineOption) { + if (secureOptions == null) { + if (isSsl2Protocol(secureProtocol) && + (!cmdLineOption || cmdLineOption.indexOf('--enable-ssl2') === -1)) { + return false; + } + + if (isSsl3Protocol(secureProtocol) && + (!cmdLineOption || cmdLineOption.indexOf('--enable-ssl3') === -1)) { + return false; + } + } else { + if (secureOptions & constants.SSL_OP_NO_SSLv2 && isSsl2Protocol(secureProtocol)) { + return false; + } + + if (secureOptions & constants.SSL_OP_NO_SSLv3 && isSsl3Protocol(secureProtocol)) { + return false; + } + } + + return true; +} + +function testSetupsCompatible(serverSetup, clientSetup) { + debug('Determing test result for:'); + debug(serverSetup); + debug(clientSetup); + + /* + * If the protocols specified by the client and server are + * not compatible (e.g SSLv2 vs SSLv3), then the test should fail. + */ + if (!secureProtocolsCompatible(serverSetup.secureProtocol, + clientSetup.secureProtocol)) { + debug('secureProtocols not compatible! server secureProtocol: ' + + serverSetup.secureProtocol + ', client secureProtocol: ' + + clientSetup.secureProtocol); + return false; + } + + /* + * If the client's options are not compatible with the server's protocol, + * then the test should fail. Same if server's options are not compatible + * with the client's protocol. + */ + if (!secureProtocolCompatibleWithSecureOptions(serverSetup.secureProtocol, + clientSetup.secureOptions, + clientSetup.cmdLine) || + !secureProtocolCompatibleWithSecureOptions(clientSetup.secureProtocol, + serverSetup.secureOptions, + serverSetup.cmdLine)) { + debug('Secure protocol not compatible with secure options!'); + return false; + } + + if (isSsl2Protocol(serverSetup.secureProtocol) || + isSsl2Protocol(clientSetup.secureProtocol)) { + + /* + * It seems that in order to be able to use SSLv2, at least the server + * *needs* to advertise at least one cipher compatible with it. + */ + if (serverSetup.ciphers !== SSL2_COMPATIBLE_CIPHERS) { + return false; + } + + /* + * If only either one of the client or server specify SSLv2 as their + * protocol, then *both* of them *need* to advertise at least one cipher + * that is compatible with SSLv2. + */ + if ((!isSsl2Protocol(serverSetup.secureProtocol) || !isSsl2Protocol(clientSetup.secureProtocol)) && + (clientSetup.ciphers !== SSL2_COMPATIBLE_CIPHERS || serverSetup.ciphers !== SSL2_COMPATIBLE_CIPHERS)) { + return false; + } + } + + return true; +} + +function sslSetupMakesSense(cmdLineOption, secureProtocol, secureOption) { + if (isSsl2Protocol(secureProtocol)) { + if (secureOption & constants.SSL_OP_NO_SSLv2 || + (secureOption == null && (!cmdLineOption || cmdLineOption.indexOf('--enable-ssl2') === -1))) { + return false; + } + } + + if (isSsl3Protocol(secureProtocol)) { + if (secureOption & constants.SSL_OP_NO_SSLv3 || + (secureOption == null && (!cmdLineOption || cmdLineOption.indexOf('--enable-ssl3') === -1))) { + return false; + } + } + + return true; +} + +function createTestsSetups() { + + var serversSetup = []; + var clientsSetup = []; + + CMD_LINE_OPTIONS.forEach(function (cmdLineOption) { + SERVER_SSL_PROTOCOLS.forEach(function (serverSecureProtocol) { + SECURE_OPTIONS.forEach(function (secureOption) { + if (sslSetupMakesSense(cmdLineOption, + serverSecureProtocol, + secureOption)) { + var serverSetup = { + cmdLine: cmdLineOption, + secureProtocol: serverSecureProtocol, + secureOptions: secureOption + }; + + serversSetup.push(serverSetup); + + if (isSsl2Protocol(serverSecureProtocol)) { + var setupWithSsl2Ciphers = xtend(serverSetup); + setupWithSsl2Ciphers.ciphers = SSL2_COMPATIBLE_CIPHERS; + serversSetup.push(setupWithSsl2Ciphers); + } + } + }); + }); + + CLIENT_SSL_PROTOCOLS.forEach(function (clientSecureProtocol) { + SECURE_OPTIONS.forEach(function (secureOption) { + if (sslSetupMakesSense(cmdLineOption, + clientSecureProtocol, + secureOption)) { + var clientSetup = { + cmdLine: cmdLineOption, + secureProtocol: clientSecureProtocol, + secureOptions: secureOption + }; + + clientsSetup.push(clientSetup); + + if (isSsl2Protocol(clientSecureProtocol)) { + var setupWithSsl2Ciphers = xtend(clientSetup); + setupWithSsl2Ciphers.ciphers = SSL2_COMPATIBLE_CIPHERS; + clientsSetup.push(setupWithSsl2Ciphers); + } + } + }); + }); + }); + + var testSetups = []; + var testId = 0; + serversSetup.forEach(function (serverSetup) { + clientsSetup.forEach(function (clientSetup) { + var testSetup = { + server: serverSetup, + client: clientSetup, + ID: testId++ + }; + + var successExpected = false; + if (testSetupsCompatible(serverSetup, clientSetup)) { + successExpected = true; + } + testSetup.successExpected = successExpected; + + testSetups.push(testSetup); + }); + }); + + return testSetups; +} + +function runServer(port, secureProtocol, secureOptions, ciphers) { + debug('Running server!'); + debug('port: ' + port); + debug('secureProtocol: ' + secureProtocol); + debug('secureOptions: ' + secureOptions); + debug('ciphers: ' + ciphers); + + var keyPath = path.join(common.fixturesDir, 'agent.key'); + var certPath = path.join(common.fixturesDir, 'agent.crt'); + + var key = fs.readFileSync(keyPath).toString(); + var cert = fs.readFileSync(certPath).toString(); + + var server = new tls.Server({ key: key, + cert: cert, + ca: [], + ciphers: ciphers, + secureProtocol: secureProtocol, + secureOptions: secureOptions + }); + + server.listen(port, function() { + process.on('message', function onChildMsg(msg) { + if (msg === 'close') { + server.close(); + process.exit(0); + } + }); + + process.send('server_listening'); + }); + + server.on('error', function onServerError(err) { + debug('Server error: ' + err); + process.exit(1); + }); + + server.on('clientError', function onClientError(err) { + debug('Client error on server: ' + err); + process.exit(1); + }); +} + +function runClient(port, secureProtocol, secureOptions, ciphers) { + debug('Running client!'); + debug('port: ' + port); + debug('secureProtocol: ' + secureProtocol); + debug('secureOptions: ' + secureOptions); + debug('ciphers: ' + ciphers); + + var con = tls.connect(port, + { + rejectUnauthorized: false, + secureProtocol: secureProtocol, + secureOptions: secureOptions + }, + function() { + + // TODO jgilli: test that sslProtocolUsed is at least as "secure" as + // "secureProtocol" + /* + * var sslProtocolUsed = con.getVersion(); + * debug('Protocol used: ' + sslProtocolUsed); + */ + + process.send('client_done'); + }); + + con.on('error', function(err) { + debug('Client could not connect:' + err); + process.exit(1); + }); +} + +function stringToSecureOptions(secureOptionsString) { + assert(typeof secureOptionsString === 'string'); + + var secureOptions; + + var optionStrings = secureOptionsString.split('|'); + optionStrings.forEach(function (option) { + if (option === 'SSL_OP_NO_SSLv2') { + secureOptions |= constants.SSL_OP_NO_SSLv2; + } + + if (option === 'SSL_OP_NO_SSLv3') { + secureOptions |= constants.SSL_OP_NO_SSLv3; + } + + if (option === '0') { + secureOptions = 0; + } + }); + + return secureOptions; +} + +function processTestCmdLineOptions(argv){ + var options = {}; + + argv.forEach(function (arg) { + var key; + var value; + + var keyValue = arg.split(':'); + var key = keyValue[0]; + + if (keyValue.length == 2 && keyValue[1].length > 0) { + value = keyValue[1]; + + if (key === 'secureOptions') { + value = stringToSecureOptions(value); + } + + if (key === 'port') { + value = +value; + } + } + + options[key] = value; + }); + + return options; +} + +function checkTestExitCode(testSetup, serverExitCode, clientExitCode) { + if (testSetup.successExpected) { + if (serverExitCode === 0 && clientExitCode === 0) { + debug('Test succeeded as expected!'); + return true; + } + } else { + if (serverExitCode !== 0 || clientExitCode !== 0) { + debug('Test failed as expected!'); + return true; + } + } + + return false; +} + +function secureOptionsToString(secureOptions) { + var secureOptsString = ''; + + if (secureOptions & constants.SSL_OP_NO_SSLv2) { + secureOptsString += 'SSL_OP_NO_SSLv2'; + } + + if (secureOptions & constants.SSL_OP_NO_SSLv3) { + secureOptsString += '|SSL_OP_NO_SSLv3'; + } + + if (secureOptions === 0) { + secureOptsString = '0'; + } + + return secureOptsString; +} + +function forkTestProcess(processType, testSetup, port) { + var argv = [ processType ]; + + if (testSetup.secureProtocol) { + argv.push('secureProtocol:' + testSetup.secureProtocol); + } else { + argv.push('secureProtocol:'); + } + + argv.push('secureOptions:' + secureOptionsToString(testSetup.secureOptions)); + + if (testSetup.ciphers) { + argv.push('ciphers:' + testSetup.ciphers); + } else { + argv.push('ciphers:'); + } + + argv.push('port:' + port); + + var forkOptions; + if (testSetup.cmdLine) { + forkOptions = { + execArgv: [ testSetup.cmdLine ] + } + } + + return fork(process.argv[1], + argv, + forkOptions); +} + +function runTest(testSetup, testDone) { + var clientSetup = testSetup.client; + var serverSetup = testSetup.server; + + assert(clientSetup); + assert(serverSetup); + + debug('Starting new test on port: ' + testSetup.port); + + debug('client setup:'); + debug(clientSetup); + + debug('server setup:'); + debug(serverSetup); + + debug('Success expected:' + testSetup.successExpected); + + var serverExitCode; + + var clientStarted = false; + var clientExitCode; + + var serverChild = forkTestProcess('server', serverSetup, testSetup.port); + assert(serverChild); + + serverChild.on('message', function onServerMsg(msg) { + if (msg === 'server_listening') { + debug('Starting client!'); + clientStarted = true; + + var clientChild = forkTestProcess('client', clientSetup, testSetup.port); + assert(clientChild); + + clientChild.on('exit', function onClientExited(exitCode) { + debug('Client exited with code:' + exitCode); + + clientExitCode = exitCode; + if (serverExitCode != null) { + var err; + if (!checkTestExitCode(testSetup, serverExitCode, clientExitCode)) + err = new Error("Test failed!"); + + return testDone(err); + } else { + if (serverChild.connected) { + serverChild.send('close'); + } + } + }); + + clientChild.on('message', function onClientMsg(msg) { + if (msg === 'client_done' && serverChild.connected) { + serverChild.send('close'); + } + }) + } + }); + + serverChild.on('exit', function onServerExited(exitCode) { + debug('Server exited with code:' + exitCode); + + serverExitCode = exitCode; + if (clientExitCode != null || !clientStarted) { + var err; + if (!checkTestExitCode(testSetup, serverExitCode, clientExitCode)) + err = new Error("Test failed!"); + + return testDone(err); + } + }); +} + +function usage() { + console.log('Usage: test-node-ssl [-j N] [--list-tests] [-s startIndex] ' + + '[-e endIndex] [-o outputFile]'); + process.exit(1); +} + +function processDriverCmdLineOptions(argv) { + var options = { + parallelTests: 1 + }; + + for (var i = 1; i < argv.length; ++i) { + if (argv[i] === '-j') { + + var nbParallelTests = +argv[i + 1]; + if (!nbParallelTests) { + usage(); + } else { + options.parallelTests = argv[++i]; + } + } + + if (argv[i] === '-s') { + var start = +argv[i + 1]; + if (!start) { + usage(); + } else { + options.start = argv[++i]; + } + } + + if (argv[i] === '-e') { + var end = +argv[i + 1]; + if (!end) { + usage(); + } else { + options.end = argv[++i]; + } + } + + if (argv[i] === '--list-tests') { + options.listTests = true; + } + + if (argv[i] === '-o') { + var outputFile = argv[i + 1]; + if (!outputFile) { + usage(); + } else { + options.outputFile = argv[++i]; + } + } + } + + return options; +} + +function outputTestResult(test, err, output) { + output.write(os.EOL); + output.write('Test:' + os.EOL); + output.write(JSON.stringify(test, null, " ")); + output.write(os.EOL); + output.write('Result:'); + output.write(err ? 'failure' : 'success'); + output.write(os.EOL); +} + +var agentType = process.argv[2]; +if (agentType === 'client' || agentType === 'server') { + var options = processTestCmdLineOptions(process.argv); + debug('secureProtocol: ' + options.secureProtocol); + debug('secureOptions: ' + options.secureOptions); + debug('ciphers:' + options.ciphers); + debug('port:' + options.port); + + if (agentType === 'client') { + runClient(options.port, + options.secureProtocol, + options.secureOptions, + options.ciphers); + } else if (agentType === 'server') { + runServer(options.port, + options.secureProtocol, + options.secureOptions, + options.ciphers); + } +} else { + var driverOptions = processDriverCmdLineOptions(process.argv); + debug('Tests driver options:'); + debug(driverOptions); + /* + * This is the tests driver process. + * + * It forks itself twice for each test. Each of the two forked processees are + * respectfully used as an SSL client and an SSL server. The client and + * server setup their SSL connection as generated by the "createTestsSetups" + * function. Once both processes have exited, the tests driver process + * compare both client and server exit codes with the expected test result + * of the test setup. If they match, the test is successful, otherwise it + * failed. + */ + + var testSetups = createTestsSetups(); + + if (driverOptions.listTests) { + console.log(testSetups); + process.exit(0); + } + + var testOutput = process.stdout; + if (driverOptions.outputFile) { + testOutput = fs.createWriteStream(driverOptions.outputFile) + .on('error', function onError(err) { + console.error(err); + process.exit(1); + }); + } + + debug('Tests setups:'); + debug('Number of tests: ' + testSetups.length); + debug(JSON.stringify(testSetups, null, " ")); + debug(); + + var nbTestsStarted = 0; + + function runTests(tests, callback) { + var nbTests = tests.length; + if (nbTests === 0) { + return callback(); + } + var error; + var nbTestsDone = 0; + + debug('Starting new batch of tests...'); + + var port = common.PORT; + async.each(tests, function (test, testDone) { + test.port = port++; + + ++nbTestsStarted; + debug('Starting test nb: ' + nbTestsStarted); + + runTest(test, function onTestDone(err) { + ++nbTestsDone; + if (err && error === undefined) { + error = new Error('Test with ID ' + test.ID + ' failed: ' + err); + } + + outputTestResult(test, err, testOutput); + + if (nbTestsDone === nbTests) + return testDone(error); + return testDone(); + }); + + }, function testsDone(err, results) { + if (err) { + assert(false, + "At least one test in the most recent batch failed: " + err); + } + + return callback(err); + }); + } + + function runAllTests(allTests, allTestsDone) { + if (allTests.length === 0) { + return allTestsDone(); + } + + return runTests(allTests.splice(0, driverOptions.parallelTests), + runAllTests.bind(global, allTests, allTestsDone)); + } + + runAllTests(testSetups.slice(driverOptions.start, driverOptions.end), + function allDone(err) { + console.log('All tests done!'); + }); +}