Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 32 additions & 9 deletions lib/base/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what happens if this throws synchronously? The caller does not catches error, and unlike async error we'll have connection in a broken state

: this.config.ssl;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth adding extra guard

if (typeof sslConfig !== 'object' || sslConfig === null) {
  onSecure(new TypeError('SSL factory must return an object'));
  return;
}

so that if user supplied factory returns garbage its much easier to debug ( the typings do cover that, but won't hurt imo )

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;
Expand Down
15 changes: 7 additions & 8 deletions lib/connection_config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion test/integration/connection/test-disconnects.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RowDataPacket[]>(
Expand Down
1 change: 0 additions & 1 deletion test/integration/connection/test-protocol-errors.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RowDataPacket[]>(query, (err, _rows, _fields) => {
Expand Down
1 change: 0 additions & 1 deletion test/integration/connection/test-quit.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
1 change: 0 additions & 1 deletion test/integration/connection/test-stream-errors.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
22 changes: 20 additions & 2 deletions test/unit/connection/test-connection_config.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
() =>
Expand All @@ -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(
() =>
Expand All @@ -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({
Expand Down
127 changes: 127 additions & 0 deletions test/unit/connection/test-dynamic-ssl-options.test.mts
Original file line number Diff line number Diff line change
@@ -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<SslFactoryResult>)
) {
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<void>((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<void>((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<void>((resolve) => {
connection.startTLS((err: unknown) => {
strict.ok(err instanceof Error);
strict.equal((err as Error).message, 'dynamic ssl failed');
resolve();
});
});

strict.equal(onSslConfigCalled, false);
});
});
36 changes: 23 additions & 13 deletions typings/mysql/lib/Connection.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -267,9 +268,18 @@ export interface ConnectionOptions {
flags?: Array<string>;

/**
* 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<SslOptions>);

/**
* Return each row as an array, not as an object.
Expand Down
23 changes: 20 additions & 3 deletions website/docs/documentation/ssl.mdx
Original file line number Diff line number Diff line change
@@ -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
Comment thread
wellwelwel marked this conversation as resolved.
| string
| SslOptions
| ((config: ConnectionConfig) => SslOptions | Promise<SslOptions>);
```

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

Expand Down Expand Up @@ -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(),
}),
});
```
Loading