diff --git a/lib/base/connection.js b/lib/base/connection.js index 845876c722..f07bb59881 100644 --- a/lib/base/connection.js +++ b/lib/base/connection.js @@ -378,17 +378,40 @@ class BaseConnection extends EventEmitter { if (this.config.debug) { console.log('Upgrading connection to TLS'); } + + const sslConfig = + typeof this.config.ssl === 'function' + ? this.config.ssl(this.config) + : this.config.ssl; + + if (typeof sslConfig.then === 'function') { + sslConfig.then( + (resolvedSslConfig) => { + this._onSslConfig(resolvedSslConfig, onSecure); + }, + (err) => { + onSecure(err); + } + ); + } else { + this._onSslConfig(sslConfig, onSecure); + } + } + + _onSslConfig(sslConfig, onSecure) { const secureContext = Tls.createSecureContext({ - ca: this.config.ssl.ca, - cert: this.config.ssl.cert, - ciphers: this.config.ssl.ciphers, - key: this.config.ssl.key, - passphrase: this.config.ssl.passphrase, - minVersion: this.config.ssl.minVersion, - maxVersion: this.config.ssl.maxVersion, + ca: sslConfig.ca, + cert: sslConfig.cert, + ciphers: sslConfig.ciphers, + key: sslConfig.key, + passphrase: sslConfig.passphrase, + minVersion: sslConfig.minVersion, + maxVersion: sslConfig.maxVersion, }); - const rejectUnauthorized = this.config.ssl.rejectUnauthorized; - const verifyIdentity = this.config.ssl.verifyIdentity; + + // Default rejectUnauthorized to true + const rejectUnauthorized = sslConfig.rejectUnauthorized !== false; + const verifyIdentity = sslConfig.verifyIdentity; const servername = Net.isIP(this.config.host) ? undefined : this.config.host; diff --git a/lib/connection_config.js b/lib/connection_config.js index 485334e620..aaa66072e3 100644 --- a/lib/connection_config.js +++ b/lib/connection_config.js @@ -141,10 +141,6 @@ class ConnectionConfig { } this.queryFormat = options.queryFormat; this.pool = options.pool || undefined; - this.ssl = - typeof options.ssl === 'string' - ? ConnectionConfig.getSSLProfile(options.ssl) - : options.ssl || false; this.multipleStatements = options.multipleStatements || false; this.rowsAsArray = options.rowsAsArray || false; this.namedPlaceholders = options.namedPlaceholders || false; @@ -158,14 +154,17 @@ class ConnectionConfig { // connection string.. this.timezone = `+${this.timezone.slice(1)}`; } + + this.ssl = + typeof options.ssl === 'string' + ? ConnectionConfig.getSSLProfile(options.ssl) + : options.ssl || false; if (this.ssl) { - if (typeof this.ssl !== 'object') { + if (typeof this.ssl !== 'object' && typeof this.ssl !== 'function') { throw new TypeError( - `SSL profile must be an object, instead it's a ${typeof this.ssl}` + `SSL configuration must be an object or a function, instead it's a ${typeof this.ssl}` ); } - // Default rejectUnauthorized to true - this.ssl.rejectUnauthorized = this.ssl.rejectUnauthorized !== false; } this.maxPacketSize = 0; this.charsetNumber = options.charset diff --git a/test/integration/connection/test-disconnects.test.mts b/test/integration/connection/test-disconnects.test.mts index 5f2285b9ca..20863171b4 100644 --- a/test/integration/connection/test-disconnects.test.mts +++ b/test/integration/connection/test-disconnects.test.mts @@ -32,7 +32,6 @@ await describe('Disconnects', async () => { host: 'localhost', // @ts-expect-error: internal access port: server._port, - // @ts-expect-error: TODO: implement typings ssl: false, }); connection.query( diff --git a/test/integration/connection/test-protocol-errors.test.mts b/test/integration/connection/test-protocol-errors.test.mts index f868195242..f2b18a62a3 100644 --- a/test/integration/connection/test-protocol-errors.test.mts +++ b/test/integration/connection/test-protocol-errors.test.mts @@ -30,7 +30,6 @@ await describe('Protocol Errors', async () => { host: 'localhost', // @ts-expect-error: internal access port: server._port, - // @ts-expect-error: TODO: implement typings ssl: false, }); connection.query(query, (err, _rows, _fields) => { diff --git a/test/integration/connection/test-quit.test.mts b/test/integration/connection/test-quit.test.mts index 73166fdc22..3f04e7b28a 100644 --- a/test/integration/connection/test-quit.test.mts +++ b/test/integration/connection/test-quit.test.mts @@ -30,7 +30,6 @@ await describe('Quit', async () => { host: 'localhost', // @ts-expect-error: internal access port: server._port, - // @ts-expect-error: TODO: implement typings ssl: false, }); diff --git a/test/integration/connection/test-stream-errors.test.mts b/test/integration/connection/test-stream-errors.test.mts index 91c8014810..b6e5a2de56 100644 --- a/test/integration/connection/test-stream-errors.test.mts +++ b/test/integration/connection/test-stream-errors.test.mts @@ -38,7 +38,6 @@ await describe('Stream Errors', async () => { host: 'localhost', // @ts-expect-error: internal access port: server._port, - // @ts-expect-error: TODO: implement typings ssl: false, }); clientConnection?.query(query, (_err) => { diff --git a/test/unit/connection/test-connection_config.test.mts b/test/unit/connection/test-connection_config.test.mts index fbdf65d504..346d6b7099 100644 --- a/test/unit/connection/test-connection_config.test.mts +++ b/test/unit/connection/test-connection_config.test.mts @@ -3,9 +3,9 @@ import ConnectionConfig from '../../../lib/connection_config.js'; import SSLProfiles from '../../../lib/constants/ssl_profiles.js'; describe('ConnectionConfig', () => { - it('should throw on boolean ssl', () => { + it('should throw on true', () => { const expectedMessage = - "SSL profile must be an object, instead it's a boolean"; + "SSL configuration must be an object or a function, instead it's a boolean"; strict.throws( () => @@ -18,6 +18,16 @@ describe('ConnectionConfig', () => { ); }); + it('should accept false', () => { + strict.doesNotThrow( + () => + new ConnectionConfig({ + ssl: false, + }), + 'Error, the constructor accepts false but throws an exception' + ); + }); + it('should accept object ssl', () => { strict.doesNotThrow( () => @@ -37,6 +47,14 @@ describe('ConnectionConfig', () => { }, 'Error, the constructor accepts a string but throws an exception'); }); + it('should accept a function', () => { + strict.doesNotThrow(() => { + new ConnectionConfig({ + ssl: () => ({}), + }); + }, 'Error, the constructor accepts a function but throws an exception'); + }); + it('should accept flags string', () => { strict.doesNotThrow(() => { new ConnectionConfig({ diff --git a/test/unit/connection/test-dynamic-ssl-options.test.mts b/test/unit/connection/test-dynamic-ssl-options.test.mts new file mode 100644 index 0000000000..edc151ab08 --- /dev/null +++ b/test/unit/connection/test-dynamic-ssl-options.test.mts @@ -0,0 +1,127 @@ +import EventEmitter from 'node:events'; +import { describe, it, strict } from 'poku'; +import BaseConnection from '../../../lib/base/connection.js'; +import ConnectionConfig from '../../../lib/connection_config.js'; + +type SslFactoryResult = { + ca?: string; + cert?: string; + key?: string; + rejectUnauthorized?: boolean; +}; + +function createMockConnection( + ssl: + | false + | (( + config: ConnectionConfig + ) => SslFactoryResult | Promise) +) { + const config = new ConnectionConfig({ + host: 'localhost', + user: 'test', + password: 'test', + database: 'test', + connectTimeout: 0, + ssl, + }); + + const mockStream = Object.assign(new EventEmitter(), { + write: () => true, + end: () => {}, + destroy() { + this.destroyed = true; + }, + destroyed: false, + setKeepAlive: () => {}, + setNoDelay: () => {}, + removeAllListeners: EventEmitter.prototype.removeAllListeners, + }); + + config.stream = mockStream; + config.isServer = true; + + return new BaseConnection({ config }); +} + +await describe('dynamic SSL options', async () => { + await it('should resolve SSL options from a synchronous function', async () => { + let capturedSslFactoryArg: ConnectionConfig | undefined; + const connection = createMockConnection((config) => { + capturedSslFactoryArg = config; + return { + ca: 'ca-data', + rejectUnauthorized: false, + }; + }); + + let capturedSslConfig: SslFactoryResult | undefined; + connection._onSslConfig = (sslConfig, onSecure) => { + capturedSslConfig = sslConfig; + onSecure(); + }; + + await new Promise((resolve, reject) => { + connection.startTLS((err: unknown) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + + strict.equal(capturedSslFactoryArg, connection.config); + strict.deepEqual(capturedSslConfig, { + ca: 'ca-data', + rejectUnauthorized: false, + }); + }); + + await it('should resolve SSL options from an asynchronous function', async () => { + const connection = createMockConnection(async () => ({ + ca: 'async-ca', + })); + + let capturedSslConfig: SslFactoryResult | undefined; + connection._onSslConfig = (sslConfig, onSecure) => { + capturedSslConfig = sslConfig; + onSecure(); + }; + + await new Promise((resolve, reject) => { + connection.startTLS((err: unknown) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); + + strict.deepEqual(capturedSslConfig, { + ca: 'async-ca', + }); + }); + + await it('should pass factory rejections to onSecure callback', async () => { + const connection = createMockConnection(async () => { + throw new Error('dynamic ssl failed'); + }); + + let onSslConfigCalled = false; + connection._onSslConfig = (_sslConfig, _onSecure) => { + onSslConfigCalled = true; + }; + + await new Promise((resolve) => { + connection.startTLS((err: unknown) => { + strict.ok(err instanceof Error); + strict.equal((err as Error).message, 'dynamic ssl failed'); + resolve(); + }); + }); + + strict.equal(onSslConfigCalled, false); + }); +}); diff --git a/typings/mysql/lib/Connection.d.ts b/typings/mysql/lib/Connection.d.ts index 7aae055581..6a84020c03 100644 --- a/typings/mysql/lib/Connection.d.ts +++ b/typings/mysql/lib/Connection.d.ts @@ -4,23 +4,24 @@ // Modifications copyright (c) 2021, Oracle and/or its affiliates. import { EventEmitter } from 'events'; -import { Readable } from 'stream'; import { Timezone } from 'sql-escaper'; -import { Query, QueryError } from './protocol/sequences/Query.js'; -import { Prepare, PrepareStatementInfo } from './protocol/sequences/Prepare.js'; +import { Readable } from 'stream'; +import { Connection as PromiseConnection } from '../../../promise.js'; +import { ConnectionConfig } from '../index.js'; +import { AuthPlugin } from './Auth.js'; +import { TypeCast } from './parsers/typeCast.js'; import { - OkPacket, + ErrorPacketParams, FieldPacket, - RowDataPacket, - ResultSetHeader, + OkPacket, OkPacketParams, - ErrorPacketParams, + ResultSetHeader, + RowDataPacket, } from './protocol/packets/index.js'; -import { Connection as PromiseConnection } from '../../../promise.js'; -import { AuthPlugin } from './Auth.js'; -import { QueryableBase } from './protocol/sequences/QueryableBase.js'; import { ExecutableBase } from './protocol/sequences/ExecutableBase.js'; -import { TypeCast } from './parsers/typeCast.js'; +import { Prepare, PrepareStatementInfo } from './protocol/sequences/Prepare.js'; +import { Query, QueryError } from './protocol/sequences/Query.js'; +import { QueryableBase } from './protocol/sequences/QueryableBase.js'; export interface SslOptions { /** @@ -267,9 +268,18 @@ export interface ConnectionOptions { flags?: Array; /** - * object with ssl parameters or a string containing name of ssl profile + * - False to disable SSL, or + * - String with the name of the SSL profile to use (supported: 'Amazon RDS'; deprecated), or + * - SSL configuration object, or + * - A function that returns a SSL configuration object + * + * Default: false */ - ssl?: string | SslOptions; + ssl?: + | false + | string + | SslOptions + | ((config: ConnectionConfig) => SslOptions | PromiseLike); /** * Return each row as an array, not as an object. diff --git a/website/docs/documentation/ssl.mdx b/website/docs/documentation/ssl.mdx index 59f6b07fa9..5e31f8d228 100644 --- a/website/docs/documentation/ssl.mdx +++ b/website/docs/documentation/ssl.mdx @@ -1,12 +1,16 @@ # SSL -As part of the connection options, you can specify the `ssl` object property or a string containing the SSL profile content (**deprecated**). +As part of the connection options, you can specify the `ssl` object to configure the TLS sockets, or set the property to `false` to not use TLS for the connection. ```ts -ssl?: string | SslOptions; +ssl?: + | false + | string + | SslOptions + | ((config: ConnectionConfig) => SslOptions | Promise); ``` -See full list of [SslOptions](https://github.com/sidorares/node-mysql2/blob/master/typings/mysql/lib/Connection.d.ts#L24-L80), which are in the same format as [tls.createSecureContext](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options). +See full list of [SslOptions](https://github.com/sidorares/node-mysql2/blob/master/typings/mysql/lib/Connection.d.ts#L26-L82), which are in the same format as [tls.createSecureContext](https://nodejs.org/api/tls.html#tls_tls_createsecurecontext_options). ## SSL Options @@ -86,3 +90,16 @@ const connection = await mysql.createConnection({ }, }); ``` + +## Dynamic SSL Configuration + +`mysql2` supports providing the SSL configuration dynamically instead of only as a static object. This is particularly useful for environments that use short-lived client certificates, such as systems using SPIFFE, where certificates may need to be fetched or rotated dynamically. If a `Promise` is returned, the connection waits for it to resolve before upgrading to TLS. + +```ts +const connection = await mysql.createConnection({ + host: 'localhost', + ssl: async () => ({ + ca: await fetchCa(), + }), +}); +```