From c02e30f0fefe549dd5128eabf21b1f031a679005 Mon Sep 17 00:00:00 2001 From: Michael Sun <47126816+MichaelSun90@users.noreply.github.com> Date: Wed, 19 Jul 2023 14:32:39 -0700 Subject: [PATCH] feat: add TDS8.0 Support for tedious (#1522) Co-authored-by: mShan0 <96149598+mShan0@users.noreply.github.com> Co-authored-by: Arthur Schreiber Co-authored-by: Arthur Schreiber --- src/connection.ts | 121 +++++++++++----- src/tds-versions.ts | 3 +- test/integration/connection-test.js | 164 +++++++++++++++++++--- test/unit/connection-config-validation.js | 36 +++++ test/unit/connection-test.ts | 45 ++++++ 5 files changed, 312 insertions(+), 57 deletions(-) create mode 100644 test/unit/connection-test.ts diff --git a/src/connection.ts b/src/connection.ts index 581fb9ab7..dcf9d8177 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,6 +1,7 @@ import crypto from 'crypto'; import os from 'os'; -import { Socket } from 'net'; +import * as tls from 'tls'; +import * as net from 'net'; import dns from 'dns'; import constants from 'constants'; @@ -344,7 +345,7 @@ export interface InternalConnectionOptions { columnEncryptionSetting: boolean; columnNameReplacer: undefined | ((colName: string, index: number, metadata: Metadata) => string); connectionRetryInterval: number; - connector: undefined | (() => Promise); + connector: undefined | (() => Promise); connectTimeout: number; connectionIsolationLevel: typeof ISOLATION_LEVEL[keyof typeof ISOLATION_LEVEL]; cryptoCredentialsDetails: SecureContextOptions; @@ -367,7 +368,7 @@ export interface InternalConnectionOptions { enableImplicitTransactions: null | boolean; enableNumericRoundabort: null | boolean; enableQuotedIdentifier: null | boolean; - encrypt: boolean; + encrypt: string | boolean; encryptionKeyStoreProviders: KeyStoreProviderMap | undefined; fallbackToDefaultDb: boolean; instanceName: undefined | string; @@ -542,7 +543,7 @@ export interface ConnectionOptions { * * (default: `undefined`) */ - connector?: () => Promise; + connector?: () => Promise; /** * The number of milliseconds before the attempt to connect is considered failed @@ -669,11 +670,12 @@ export interface ConnectionOptions { enableQuotedIdentifier?: boolean; /** - * A boolean determining whether or not the connection will be encrypted. Set to `true` if you're on Windows Azure. + * A string value that can be only set to 'strict', which indicates the usage TDS 8.0 protocol. Otherwise, + * a boolean determining whether or not the connection will be encrypted. * - * (default: `false`) + * (default: `true`) */ - encrypt?: boolean; + encrypt?: string | boolean; /** * By default, if the database requested by [[database]] cannot be accessed, @@ -829,6 +831,10 @@ export interface ConnectionOptions { */ trustServerCertificate?: boolean; + /** + * + */ + serverName?: string; /** * A boolean determining whether to return rows as arrays or key-value collections. * @@ -994,7 +1000,7 @@ class Connection extends EventEmitter { /** * @private */ - socket: undefined | Socket; + socket: undefined | net.Socket; /** * @private */ @@ -1499,10 +1505,11 @@ class Connection extends EventEmitter { this.config.options.enableQuotedIdentifier = config.options.enableQuotedIdentifier; } - if (config.options.encrypt !== undefined) { if (typeof config.options.encrypt !== 'boolean') { - throw new TypeError('The "config.options.encrypt" property must be of type boolean.'); + if (config.options.encrypt !== 'strict') { + throw new TypeError('The "encrypt" property must be set to "strict", or of type boolean.'); + } } this.config.options.encrypt = config.options.encrypt; @@ -1662,6 +1669,13 @@ class Connection extends EventEmitter { this.config.options.trustServerCertificate = config.options.trustServerCertificate; } + if (config.options.serverName !== undefined) { + if (typeof config.options.serverName !== 'string') { + throw new TypeError('The "config.options.serverName" property must be of type string.'); + } + this.config.options.serverName = config.options.serverName; + } + if (config.options.useColumnNames !== undefined) { if (typeof config.options.useColumnNames !== 'boolean') { throw new TypeError('The "config.options.useColumnNames" property must be of type boolean.'); @@ -1976,7 +1990,49 @@ class Connection extends EventEmitter { return new TokenStreamParser(message, this.debug, handler, this.config.options); } - connectOnPort(port: number, multiSubnetFailover: boolean, signal: AbortSignal, customConnector?: () => Promise) { + socketHandlingForSendPreLogin(socket: net.Socket) { + socket.on('error', (error) => { this.socketError(error); }); + socket.on('close', () => { this.socketClose(); }); + socket.on('end', () => { this.socketEnd(); }); + socket.setKeepAlive(true, KEEP_ALIVE_INITIAL_DELAY); + + this.messageIo = new MessageIO(socket, this.config.options.packetSize, this.debug); + this.messageIo.on('secure', (cleartext) => { this.emit('secure', cleartext); }); + + this.socket = socket; + + this.closed = false; + this.debug.log('connected to ' + this.config.server + ':' + this.config.options.port); + + this.sendPreLogin(); + this.transitionTo(this.STATE.SENT_PRELOGIN); + } + + wrapWithTls(socket: net.Socket): Promise { + return new Promise((resolve, reject) => { + const secureContext = tls.createSecureContext(this.secureContextOptions); + // If connect to an ip address directly, + // need to set the servername to an empty string + // if the user has not given a servername explicitly + const serverName = !net.isIP(this.config.server) ? this.config.server : ''; + const encryptOptions = { + host: this.config.server, + socket: socket, + ALPNProtocols: ['tds/8.0'], + secureContext: secureContext, + servername: this.config.options.serverName ? this.config.options.serverName : serverName, + }; + + const encryptsocket = tls.connect(encryptOptions, () => { + encryptsocket.removeListener('error', reject); + resolve(encryptsocket); + }); + + encryptsocket.once('error', reject); + }); + } + + connectOnPort(port: number, multiSubnetFailover: boolean, signal: AbortSignal, customConnector?: () => Promise) { const connectOpts = { host: this.routingData ? this.routingData.server : this.config.server, port: this.routingData ? this.routingData.port : port, @@ -1985,26 +2041,24 @@ class Connection extends EventEmitter { const connect = customConnector || (multiSubnetFailover ? connectInParallel : connectInSequence); - connect(connectOpts, dns.lookup, signal).then((socket) => { - process.nextTick(() => { - socket.on('error', (error) => { this.socketError(error); }); - socket.on('close', () => { this.socketClose(); }); - socket.on('end', () => { this.socketEnd(); }); - socket.setKeepAlive(true, KEEP_ALIVE_INITIAL_DELAY); - - this.messageIo = new MessageIO(socket, this.config.options.packetSize, this.debug); - this.messageIo.on('secure', (cleartext) => { this.emit('secure', cleartext); }); + (async () => { + let socket = await connect(connectOpts, dns.lookup, signal); - this.socket = socket; + if (this.config.options.encrypt === 'strict') { + try { + // Wrap the socket with TLS for TDS 8.0 + socket = await this.wrapWithTls(socket); + } catch (err) { + socket.end(); - this.closed = false; - this.debug.log('connected to ' + this.config.server + ':' + this.config.options.port); + throw err; + } + } - this.sendPreLogin(); - this.transitionTo(this.STATE.SENT_PRELOGIN); - }); - }, (err) => { + this.socketHandlingForSendPreLogin(socket); + })().catch((err) => { this.clearConnectTimer(); + if (err.name === 'AbortError') { return; } @@ -2251,10 +2305,12 @@ class Connection extends EventEmitter { * @private */ sendPreLogin() { - const [ , major, minor, build ] = /^(\d+)\.(\d+)\.(\d+)/.exec(version) ?? [ '0.0.0', '0', '0', '0' ]; - + const [, major, minor, build] = /^(\d+)\.(\d+)\.(\d+)/.exec(version) ?? ['0.0.0', '0', '0', '0']; const payload = new PreloginPayload({ - encrypt: this.config.options.encrypt, + // If encrypt setting is set to 'strict', then we should have already done the encryption before calling + // this function. Therefore, the encrypt will be set to false here. + // Otherwise, we will set encrypt here based on the encrypt Boolean value from the configuration. + encrypt: typeof this.config.options.encrypt === 'boolean' && this.config.options.encrypt, version: { major: Number(major), minor: Number(minor), build: Number(build), subbuild: 0 } }); @@ -3176,8 +3232,7 @@ Connection.prototype.STATE = { if (preloginPayload.fedAuthRequired === 1) { this.fedAuthRequired = true; } - - if (preloginPayload.encryptionString === 'ON' || preloginPayload.encryptionString === 'REQ') { + if ('strict' !== this.config.options.encrypt && (preloginPayload.encryptionString === 'ON' || preloginPayload.encryptionString === 'REQ')) { if (!this.config.options.encrypt) { this.emit('connect', new ConnectionError("Server requires encryption, set 'encrypt' config option to true.", 'EENCRYPT')); return this.close(); @@ -3185,7 +3240,7 @@ Connection.prototype.STATE = { try { this.transitionTo(this.STATE.SENT_TLSSSLNEGOTIATION); - await this.messageIo.startTls(this.secureContextOptions, this.routingData?.server ?? this.config.server, this.config.options.trustServerCertificate); + await this.messageIo.startTls(this.secureContextOptions, this.config.options.serverName ? this.config.options.serverName : this.routingData?.server ?? this.config.server, this.config.options.trustServerCertificate); } catch (err: any) { return this.socketError(err); } diff --git a/src/tds-versions.ts b/src/tds-versions.ts index 10e747997..7a1ebd90e 100644 --- a/src/tds-versions.ts +++ b/src/tds-versions.ts @@ -3,7 +3,8 @@ export const versions: { [key: string]: number } = { '7_2': 0x72090002, '7_3_A': 0x730A0003, '7_3_B': 0x730B0003, - '7_4': 0x74000004 + '7_4': 0x74000004, + '8_0': 0x08000000 }; export const versionsByValue: { [key: number]: string } = {}; diff --git a/test/integration/connection-test.js b/test/integration/connection-test.js index 2022c4588..24b111a4a 100644 --- a/test/integration/connection-test.js +++ b/test/integration/connection-test.js @@ -9,6 +9,7 @@ const os = require('os'); import Connection from '../../src/connection'; import { ConnectionError, RequestError } from '../../src/errors'; import Request from '../../src/request'; +import { versions } from '../../src/tds-versions'; function getConfig() { const config = JSON.parse( @@ -28,10 +29,6 @@ function getConfig() { return config; } -process.on('uncaughtException', function(err) { - console.error(err.stack); -}); - function getInstanceName() { return JSON.parse( fs.readFileSync(homedir + '/.tedious/test-connection.json', 'utf8') @@ -530,38 +527,159 @@ describe('Ntlm Test', function() { }); describe('Encrypt Test', function() { - it('should encrypt', function(done) { - const config = getConfig(); - config.options.encrypt = true; - + /** + * @param {any} config + * @param {(err: Error | null, supportsTds8?: boolean) => void} callback + */ + function supportsTds8(config, callback) { const connection = new Connection(config); - connection.connect(function(err) { - assert.ifError(err); + connection.connect((err) => { + if (err) { + return callback(err); + } - connection.close(); + /** + * @type {string | undefined} + */ + let productMajorVersion; + const request = new Request("SELECT SERVERPROPERTY('ProductMajorVersion')", (err) => { + if (err) { + connection.close(); + return callback(err); + } + + if (!productMajorVersion || productMajorVersion < '2022') { + connection.close(); + return callback(null, false); + } + + if (productMajorVersion > '2022') { + connection.close(); + return callback(null, true); + } + + let supportsTds8 = false; + const request = new Request('SELECT host_platform FROM sys.dm_os_host_info', (err) => { + connection.close(); + + if (err) { + return callback(err); + } + + callback(null, supportsTds8); + }); + + request.on('row', (row) => { + supportsTds8 = row[0].value !== 'Linux'; + }); + + connection.execSql(request); + }); + + request.on('row', (row) => { + productMajorVersion = row[0].value; + }); + + connection.execSql(request); }); + } - connection.on('end', function() { - done(); + describe('with strict encryption enabled (TDS 8.0)', function() { + /** + * @type {Connection} + */ + let connection; + + beforeEach(function(done) { + const config = getConfig(); + + supportsTds8(config, (err, supportsTds8) => { + if (err) { + return done(err); + } + + if (!supportsTds8) { + return this.skip(); + } + + config.options.encrypt = 'strict'; + + connection = new Connection(config); + connection.connect(done); + }); }); - connection.on('databaseChange', function(database) { - assert.strictEqual(database, config.options.database); + afterEach(function() { + connection && connection.close(); }); - connection.on('secure', function(cleartext) { - assert.ok(cleartext); - assert.ok(cleartext.getCipher()); - assert.ok(cleartext.getPeerCertificate()); + it('opens an encrypted connection', function(done) { + const request = new Request(` + SELECT c.protocol_version, c.encrypt_option + FROM sys.dm_exec_connections AS c + WHERE c.session_id = @@SPID + `, (err, rowCount) => { + if (err) { + return done(err); + } + + assert.ifError(err); + assert.strictEqual(rowCount, 1); + + done(); + }); + + request.on('row', function(columns) { + assert.strictEqual(columns.length, 2); + assert.strictEqual(versions['8_0'], columns[0].value); + assert.strictEqual('TRUE', columns[1].value); + }); + + connection.execSql(request); }); + }); - connection.on('infoMessage', function(info) { - // console.log("#{info.number} : #{info.message}") + describe('with encryption enabled', function() { + /** + * @type {Connection} + */ + let connection; + + beforeEach(function(done) { + const config = getConfig(); + config.options.encrypt = true; + connection = new Connection(config); + connection.connect(done); }); - return connection.on('debug', function(text) { - // console.log(text) + afterEach(function() { + connection.close(); + }); + + it('opens an encrypted connection', function(done) { + const request = new Request(` + SELECT c.protocol_version, c.encrypt_option + FROM sys.dm_exec_connections AS c + WHERE c.session_id = @@SPID + `, (err, rowCount) => { + if (err) { + return done(err); + } + + assert.ifError(err); + assert.strictEqual(rowCount, 1); + + done(); + }); + + request.on('row', function(columns) { + assert.strictEqual(columns.length, 2); + assert.strictEqual(versions[connection.config.options.tdsVersion], columns[0].value); + assert.strictEqual('TRUE', columns[1].value); + }); + + connection.execSql(request); }); }); }); diff --git a/test/unit/connection-config-validation.js b/test/unit/connection-config-validation.js index df94c6c0a..5ecb0e9ea 100644 --- a/test/unit/connection-config-validation.js +++ b/test/unit/connection-config-validation.js @@ -94,4 +94,40 @@ describe('Connection configuration validation', function() { new Connection(config); }); }); + + it('bad encrypt value type', () => { + const numberEncrypt = 0; + config.options.encrypt = numberEncrypt; + assert.throws(() => { + new Connection(config); + }); + }); + + it('bad encrypt string', () => { + config.options.encrypt = 'false'; + assert.throws(() => { + new Connection(config); + }); + }); + + it('good false encrypt value', () => { + config.options.encrypt = false; + const connection = new Connection(config); + assert.strictEqual(connection.config.options.encrypt, false); + ensureConnectionIsClosed(connection, () => {}); + }); + + it('good true encrypt value', () => { + config.options.encrypt = true; + const connection = new Connection(config); + assert.strictEqual(connection.config.options.encrypt, true); + ensureConnectionIsClosed(connection, () => {}); + }); + + it('good strict encrypt value', () => { + config.options.encrypt = 'strict'; + const connection = new Connection(config); + assert.strictEqual(connection.config.options.encrypt, 'strict'); + ensureConnectionIsClosed(connection, () => {}); + }); }); diff --git a/test/unit/connection-test.ts b/test/unit/connection-test.ts new file mode 100644 index 000000000..4067fa3d2 --- /dev/null +++ b/test/unit/connection-test.ts @@ -0,0 +1,45 @@ +import * as net from 'net'; +import { Connection } from '../../src/tedious'; +import { assert } from 'chai'; + +describe('Using `strict` encryption', function() { + let server: net.Server; + + beforeEach(function(done) { + server = net.createServer(); + server.listen(0, '127.0.0.1', done); + }); + + afterEach(function(done) { + server.close(done); + }); + + it('does not throw an unhandled exception if the tls handshake fails', function(done) { + server.on('connection', (connection) => { + console.log('incoming connection'); + + connection.on('data', () => { + // Ignore all incoming data + }); + + setTimeout(() => { + connection.end(); + }, 50); + }); + + const connection = new Connection({ + server: (server.address() as net.AddressInfo).address, + options: { + port: (server.address() as net.AddressInfo).port, + encrypt: 'strict' + } + }); + + connection.connect((err) => { + assert.instanceOf(err, Error); + assert.include(err!.message, 'Client network socket disconnected before secure TLS connection was established'); + + done(); + }); + }); +});