diff --git a/package.json b/package.json index 1328c34f913..b3a7e1c47ec 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "check:oidc-azure": "mocha --config test/mocha_mongodb.json test/integration/auth/mongodb_oidc_azure.prose.test.ts", "check:ocsp": "mocha --config test/manual/mocharc.json test/manual/ocsp_support.test.js", "check:kerberos": "nyc mocha --config test/manual/mocharc.json test/manual/kerberos.test.ts", - "check:tls": "mocha --config test/manual/mocharc.json test/manual/tls_support.test.js", + "check:tls": "mocha --config test/manual/mocharc.json test/manual/tls_support.test.ts", "check:ldap": "nyc mocha --config test/manual/mocharc.json test/manual/ldap.test.js", "check:socks5": "mocha --config test/manual/mocharc.json test/manual/socks5.test.ts", "check:csfle": "mocha --config test/mocha_mongodb.json test/integration/client-side-encryption", diff --git a/src/connection_string.ts b/src/connection_string.ts index b71ec691494..a6a1677412f 100644 --- a/src/connection_string.ts +++ b/src/connection_string.ts @@ -1,5 +1,4 @@ import * as dns from 'dns'; -import * as fs from 'fs'; import ConnectionString from 'mongodb-connection-string-url'; import { URLSearchParams } from 'url'; @@ -1097,16 +1096,10 @@ export const OPTIONS = { } }, tlsCAFile: { - target: 'ca', - transform({ values: [value] }) { - return fs.readFileSync(String(value), { encoding: 'ascii' }); - } + type: 'string' }, tlsCertificateKeyFile: { - target: 'key', - transform({ values: [value] }) { - return fs.readFileSync(String(value), { encoding: 'ascii' }); - } + type: 'string' }, tlsCertificateKeyFilePassword: { target: 'passphrase', diff --git a/src/mongo_client.ts b/src/mongo_client.ts index 0de3e8e0799..b4203b07d75 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -1,3 +1,4 @@ +import { promises as fs } from 'fs'; import type { TcpNetConnectOpts } from 'net'; import type { ConnectionOptions as TLSConnectionOptions, TLSSocketOptions } from 'tls'; import { promisify } from 'util'; @@ -433,6 +434,14 @@ export class MongoClient extends TypedEventEmitter { const options = this[kOptions]; + if (options.tls) { + if (typeof options.tlsCAFile === 'string') { + options.ca ??= await fs.readFile(options.tlsCAFile, { encoding: 'utf8' }); + } + if (typeof options.tlsCertificateKeyFile === 'string') { + options.key ??= await fs.readFile(options.tlsCertificateKeyFile, { encoding: 'utf8' }); + } + } if (typeof options.srvHost === 'string') { const hosts = await resolveSRVRecord(options); @@ -767,7 +776,7 @@ export interface MongoOptions * * ### Additional options: * - * | nodejs native option | driver spec compliant option name | driver option type | + * | nodejs native option | driver spec equivalent option name | driver option type | * |:----------------------|:----------------------------------------------|:-------------------| * | `ca` | `tlsCAFile` | `string` | * | `crl` | N/A | `string` | @@ -783,9 +792,20 @@ export interface MongoOptions * If `tlsInsecure` is set to `false`, then it will set the node native options `checkServerIdentity` * to a no-op and `rejectUnauthorized` to the inverse value of `tlsAllowInvalidCertificates`. If * `tlsAllowInvalidCertificates` is not set, then `rejectUnauthorized` will be set to `true`. + * + * ### Note on `tlsCAFile` and `tlsCertificateKeyFile` + * + * The files specified by the paths passed in to the `tlsCAFile` and `tlsCertificateKeyFile` fields + * are read lazily on the first call to `MongoClient.connect`. Once these files have been read and + * the `ca` and `key` fields are populated, they will not be read again on subsequent calls to + * `MongoClient.connect`. As a result, until the first call to `MongoClient.connect`, the `ca` + * and `key` fields will be undefined. */ tls: boolean; + tlsCAFile?: string; + tlsCertificateKeyFile?: string; + /** @internal */ [featureFlag: symbol]: any; diff --git a/test/manual/mocharc.json b/test/manual/mocharc.json index b129ce1fb9b..b52cf660c22 100644 --- a/test/manual/mocharc.json +++ b/test/manual/mocharc.json @@ -2,5 +2,6 @@ "require": "ts-node/register", "reporter": "test/tools/reporter/mongodb_reporter.js", "failZero": true, - "color": true + "color": true, + "timeout": 10000 } diff --git a/test/manual/tls_support.test.js b/test/manual/tls_support.test.js deleted file mode 100644 index f6bbca59ebf..00000000000 --- a/test/manual/tls_support.test.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; -const { MongoClient } = require('../mongodb'); -const { LEGACY_HELLO_COMMAND } = require('../mongodb'); - -const REQUIRED_ENV = ['MONGODB_URI', 'SSL_KEY_FILE', 'SSL_CA_FILE']; - -describe('TLS Support', function () { - for (let key of REQUIRED_ENV) { - if (process.env[key] == null) { - throw new Error(`skipping SSL tests, ${key} environment variable is not defined`); - } - } - - const connectionString = process.env.MONGODB_URI; - const tlsCertificateKeyFile = process.env.SSL_KEY_FILE; - const tlsCAFile = process.env.SSL_CA_FILE; - const tlsSettings = { tls: true, tlsCertificateKeyFile, tlsCAFile }; - - it( - 'should connect with tls via client options', - makeConnectionTest(connectionString, tlsSettings) - ); - - it( - 'should connect with tls via url options', - makeConnectionTest( - `${connectionString}?${Object.keys(tlsSettings) - .map(key => `${key}=${tlsSettings[key]}`) - .join('&')}` - ) - ); -}); - -function makeConnectionTest(connectionString, clientOptions) { - return function () { - const client = new MongoClient(connectionString, clientOptions); - - return client - .connect() - .then(() => client.db('admin').command({ [LEGACY_HELLO_COMMAND]: 1 })) - .then(() => client.db('test').collection('test').findOne({})) - .then(() => client.close()); - }; -} diff --git a/test/manual/tls_support.test.ts b/test/manual/tls_support.test.ts new file mode 100644 index 00000000000..179527b1c4b --- /dev/null +++ b/test/manual/tls_support.test.ts @@ -0,0 +1,120 @@ +import { expect } from 'chai'; +import { promises as fs } from 'fs'; + +import { LEGACY_HELLO_COMMAND, MongoClient, type MongoClientOptions } from '../mongodb'; + +const REQUIRED_ENV = ['MONGODB_URI', 'SSL_KEY_FILE', 'SSL_CA_FILE']; + +describe('TLS Support', function () { + for (const key of REQUIRED_ENV) { + if (process.env[key] == null) { + throw new Error(`skipping SSL tests, ${key} environment variable is not defined`); + } + } + + const CONNECTION_STRING = process.env.MONGODB_URI as string; + const TLS_CERT_KEY_FILE = process.env.SSL_KEY_FILE as string; + const TLS_CA_FILE = process.env.SSL_CA_FILE as string; + const tlsSettings = { + tls: true, + tlsCertificateKeyFile: TLS_CERT_KEY_FILE, + tlsCAFile: TLS_CA_FILE + }; + + it( + 'should connect with tls via client options', + makeConnectionTest(CONNECTION_STRING, tlsSettings) + ); + + it( + 'should connect with tls via url options', + makeConnectionTest( + `${CONNECTION_STRING}?${Object.keys(tlsSettings) + .map(key => `${key}=${tlsSettings[key]}`) + .join('&')}` + ) + ); + + context('when tls filepaths are provided', () => { + let client: MongoClient; + afterEach(async () => { + if (client) await client.close(); + }); + + context('when tls filepaths have length > 0', () => { + beforeEach(async () => { + client = new MongoClient(CONNECTION_STRING, tlsSettings); + }); + + it('should read in files async at connect time', async () => { + expect(client.options).property('tlsCAFile', TLS_CA_FILE); + expect(client.options).property('tlsCertificateKeyFile', TLS_CERT_KEY_FILE); + expect(client.options).not.have.property('ca'); + expect(client.options).not.have.property('key'); + + await client.connect(); + + expect(client.options).property('ca').to.exist; + expect(client.options).property('key').to.exist; + }); + + context('when client has been opened and closed more than once', function () { + it('should only read files once', async () => { + await client.connect(); + await client.close(); + + const caFileAccessTime = (await fs.stat(TLS_CA_FILE)).atime; + const certKeyFileAccessTime = (await fs.stat(TLS_CERT_KEY_FILE)).atime; + + await client.connect(); + + expect((await fs.stat(TLS_CA_FILE)).atime).to.deep.equal(caFileAccessTime); + expect((await fs.stat(TLS_CERT_KEY_FILE)).atime).to.deep.equal(certKeyFileAccessTime); + }); + }); + }); + + context('when tlsCAFile has length === 0', () => { + beforeEach(() => { + client = new MongoClient(CONNECTION_STRING, { + tls: true, + tlsCAFile: '', + tlsCertificateKeyFile: TLS_CERT_KEY_FILE + }); + }); + + it('should throw an error at connect time', async () => { + const err = await client.connect().catch(e => e); + + expect(err).to.be.instanceof(Error); + }); + }); + + context('when tlsCertificateKeyFile has length === 0', () => { + beforeEach(() => { + client = new MongoClient(CONNECTION_STRING, { + tls: true, + tlsCAFile: TLS_CA_FILE, + tlsCertificateKeyFile: '' + }); + }); + + it('should throw an error at connect time', async () => { + const err = await client.connect().catch(e => e); + + expect(err).to.be.instanceof(Error); + }); + }); + }); +}); + +function makeConnectionTest(connectionString: string, clientOptions?: MongoClientOptions) { + return async function () { + const client = new MongoClient(connectionString, clientOptions); + + await client.connect(); + await client.db('admin').command({ [LEGACY_HELLO_COMMAND]: 1 }); + await client.db('test').collection('test').findOne({}); + return await client.close(); + }; +} diff --git a/test/tools/uri_spec_runner.ts b/test/tools/uri_spec_runner.ts index e7f493f3897..3c89f93e1cf 100644 --- a/test/tools/uri_spec_runner.ts +++ b/test/tools/uri_spec_runner.ts @@ -314,13 +314,13 @@ export function executeUriValidationTest( .equal(optionValue); break; case 'tlsCertificateKeyFile': - expectedProp = 'key'; + expectedProp = 'tlsCertificateKeyFile'; expect(options, `${errorMessage} ${optionKey} -> ${expectedProp}`) .to.have.property(expectedProp) .equal(optionValue); break; case 'tlsCAFile': - expectedProp = 'ca'; + expectedProp = 'tlsCAFile'; expect(options, `${errorMessage} ${optionKey} -> ${expectedProp}`) .to.have.property(expectedProp) .equal(optionValue); diff --git a/test/unit/mongo_client.test.js b/test/unit/mongo_client.test.js index c2199250f1f..2869a7d270e 100644 --- a/test/unit/mongo_client.test.js +++ b/test/unit/mongo_client.test.js @@ -44,7 +44,7 @@ describe('MongoOptions', function () { * * ### Additional options: * - * | nodejs native option | driver spec compliant option name | driver option type | + * | nodejs native option | driver spec equivalent option name | driver option type | * |:----------------------|:----------------------------------------------|:-------------------| * | `ca` | `tlsCAFile` | `string` | * | `crl` | N/A | `string` | @@ -55,12 +55,11 @@ describe('MongoOptions', function () { * | see note below | `tlsInsecure` | `boolean` | * */ - expect(options).to.not.have.property('tlsCertificateKeyFile'); - expect(options).to.not.have.property('tlsCAFile'); expect(options).to.not.have.property('tlsCertificateKeyFilePassword'); - expect(options).has.property('ca', ''); - expect(options).has.property('key'); - expect(options.key).has.length(0); + expect(options).to.not.have.property('key'); + expect(options).to.not.have.property('ca'); + expect(options).to.have.property('tlsCertificateKeyFile', filename); + expect(options).to.have.property('tlsCAFile', filename); expect(options).has.property('passphrase', 'tlsCertificateKeyFilePassword'); expect(options).has.property('tls', true); }); @@ -394,10 +393,10 @@ describe('MongoOptions', function () { const optsFromObject = parseOptions('mongodb://localhost/', { tlsCertificateKeyFile: 'testCertKey.pem' }); - expect(optsFromObject).to.have.property('key', 'cert key'); + expect(optsFromObject).to.have.property('tlsCertificateKeyFile', 'testCertKey.pem'); const optsFromUri = parseOptions('mongodb://localhost?tlsCertificateKeyFile=testCertKey.pem'); - expect(optsFromUri).to.have.property('key', 'cert key'); + expect(optsFromUri).to.have.property('tlsCertificateKeyFile', 'testCertKey.pem'); }); });