Skip to content

Commit 11b3ac6

Browse files
authored
fix(devtools-connect): fix TLS error fallback mechanism MONGOSH-2488 (#566)
Restructure our proxy integration logic to: - Avoid modifying the OIDC options – previously we were assigning to `clientOptions.oidc`, which meant that the `fetch` function created to read the system certificates was also used for the non-system-cert `connect()` call, breaking our fallback mechanism - Add a test that verifies that the fallback mechanism works both for TLS driver connections and HTTPS OIDC calls. - Deduplicate the logic for adding CA options to the `DevtoolsProxyOptions` instances. - Share log info from the OIDC agent instance, if a new one has been created. - Make sure that TLS errors passed through node-fetch are actually picked up by our "nested error" detection logic (they have a different `toStringTag` defined than regular `Error` instances).
1 parent cc9ebda commit 11b3ac6

File tree

8 files changed

+133
-32
lines changed

8 files changed

+133
-32
lines changed

packages/devtools-connect/src/connect.spec.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { MongoClient } from 'mongodb';
66
import sinon, { stubConstructor } from 'ts-sinon';
77
import chai, { expect } from 'chai';
88
import sinonChai from 'sinon-chai';
9+
import { Agent as HTTPSAgent } from 'https';
910

1011
chai.use(sinonChai);
1112

@@ -375,7 +376,7 @@ describe('devtools connect', function () {
375376
});
376377

377378
describe('retryable TLS errors', function () {
378-
it('retries TLS errors without system CA integration enabled', async function () {
379+
it('retries TLS errors without system CA integration enabled -- MongoClient error', async function () {
379380
const uri = 'localhost:27017';
380381
const mClient = new FakeMongoClient();
381382
const mClientType = sinon.stub().returns(mClient);
@@ -429,6 +430,67 @@ describe('devtools connect', function () {
429430

430431
expect(earlyFailures).to.equal(2);
431432
});
433+
434+
it('retries TLS errors without system CA integration enabled -- OIDC error', async function () {
435+
const uri = 'localhost:27017';
436+
const mClient = new FakeMongoClient();
437+
let opts: DevtoolsConnectOptions;
438+
439+
const proxyConnectEvents: any[] = [];
440+
bus.on('proxy:connect', (ev) => {
441+
proxyConnectEvents.push({ ...ev, opts: { ...ev.opts } });
442+
ev.opts.ca = 'abcdef'; // will always fail
443+
ev.agent.httpsAgent = new HTTPSAgent({ ca: 'abcdef' });
444+
});
445+
const mClientType = sinon.stub().callsFake((url, clientOpts) => {
446+
opts = clientOpts;
447+
return mClient;
448+
});
449+
450+
mClient.connect = sinon.stub().callsFake(async () => {
451+
return await opts.authMechanismProperties?.OIDC_HUMAN_CALLBACK?.({
452+
version: 1,
453+
timeoutContext: new AbortController().signal,
454+
idpInfo: {
455+
issuer: 'https://mongodb.com/',
456+
clientId: 'meow',
457+
},
458+
});
459+
});
460+
mClient.topology = {
461+
description: {
462+
servers: new Map([['localhost:27017', {}]]),
463+
},
464+
};
465+
466+
try {
467+
await connectMongoClient(
468+
uri,
469+
{
470+
...defaultOpts,
471+
authMechanism: 'MONGODB-OIDC',
472+
},
473+
bus,
474+
mClientType as any,
475+
);
476+
expect.fail('missed exception');
477+
} catch (err: any) {
478+
expect(err.message).to.include('Unable to fetch issuer metadata');
479+
}
480+
481+
expect(mClientType.getCalls()).to.have.lengthOf(2);
482+
expect((mClient as any).connect.getCalls()).to.have.lengthOf(2);
483+
expect(proxyConnectEvents).to.have.lengthOf(2);
484+
expect(proxyConnectEvents[0].agent).to.not.equal(
485+
proxyConnectEvents[1].agent,
486+
);
487+
expect(proxyConnectEvents[0].opts.ca).to.not.equal(
488+
proxyConnectEvents[1].opts.ca,
489+
);
490+
expect(proxyConnectEvents[0].agent.proxyOptions.ca).to.not.equal(
491+
proxyConnectEvents[1].agent.proxyOptions.ca,
492+
);
493+
});
432494
});
433495
});
434496

packages/devtools-connect/src/connect.ts

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
Tunnel,
3333
systemCA,
3434
createFetch,
35+
isExistingAgentInstance,
3536
} from '@mongodb-js/devtools-proxy-support';
3637
export type { DevtoolsProxyOptions, AgentWithInitialize };
3738

@@ -490,19 +491,25 @@ async function connectMongoClientImpl({
490491
ca = caWithSystemCerts;
491492
}
492493

494+
const addCAToProxyOptions = (
495+
opts: AgentWithInitialize | DevtoolsProxyOptions | undefined,
496+
): AgentWithInitialize | DevtoolsProxyOptions => {
497+
if (opts && isExistingAgentInstance(opts)) return opts;
498+
return {
499+
caExcludeSystemCerts: !useSystemCA,
500+
...opts,
501+
...(ca ? { ca } : {}),
502+
};
503+
};
504+
493505
// Create a proxy agent, if requested. `useOrCreateAgent()` takes a target argument
494506
// that can be used to select a proxy for a specific procotol or host;
495507
// here we specify 'mongodb://' if we only intend to use the proxy for database
496508
// connectivity.
497509
const proxyAgent =
498510
clientOptions.proxy &&
499511
useOrCreateAgent(
500-
'createConnection' in clientOptions.proxy
501-
? clientOptions.proxy
502-
: {
503-
...(clientOptions.proxy as DevtoolsProxyOptions),
504-
...(ca ? { ca } : {}),
505-
},
512+
addCAToProxyOptions(clientOptions.proxy),
506513
clientOptions.applyProxyToOIDC === true ? undefined : 'mongodb://',
507514
);
508515
cleanupOnClientClose.push(() => proxyAgent?.destroy());
@@ -516,17 +523,16 @@ async function connectMongoClientImpl({
516523
} else if (clientOptions.applyProxyToOIDC) {
517524
oidcProxyOptions = clientOptions.applyProxyToOIDC;
518525
}
519-
if (!oidcProxyOptions && ca) {
520-
oidcProxyOptions = { ca };
521-
}
522-
if (oidcProxyOptions) {
523-
clientOptions.oidc = {
524-
customFetch: createFetch(
525-
oidcProxyOptions,
526-
) as unknown as MongoDBOIDCPluginOptions['customFetch'],
526+
527+
const oidcFetch = createFetch(addCAToProxyOptions(oidcProxyOptions));
528+
clientOptions = {
529+
...clientOptions,
530+
oidc: {
531+
customFetch:
532+
oidcFetch as unknown as MongoDBOIDCPluginOptions['customFetch'],
527533
...clientOptions.oidc,
528-
};
529-
}
534+
},
535+
};
530536

531537
let tunnel: Tunnel | undefined;
532538
if (proxyAgent && !hasProxyHostOption(uri, clientOptions)) {
@@ -537,7 +543,11 @@ async function connectMongoClientImpl({
537543
);
538544
cleanupOnClientClose.push(() => tunnel?.close());
539545
}
540-
for (const proxyLogger of new Set([tunnel?.logger, proxyAgent?.logger])) {
546+
for (const proxyLogger of new Set([
547+
tunnel?.logger,
548+
proxyAgent?.logger,
549+
oidcFetch?.agent?.logger,
550+
])) {
541551
if (proxyLogger) {
542552
copyEventEmitterEvents(proxyLogger, logger);
543553
}

packages/devtools-connect/src/fast-failure-connect.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ function handleNestedErrors(
7171
): (err: unknown) => boolean {
7272
const checker = (err: unknown): boolean => {
7373
if (
74-
Object.prototype.toString.call(err).toLowerCase() !== '[object error]'
74+
// e.g. node-fetch defines its FetchError class to be a `[object FetchError]`
75+
!Object.prototype.toString.call(err).match(/\[object .*error\]/i)
7576
) {
7677
return checker(new Error(String(err)));
7778
}

packages/devtools-proxy-support/src/agent.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class DevtoolsProxyAgent extends ProxyAgent implements AgentWithInitialize {
101101
}
102102
this._req = req;
103103
this._reqLock = new Promise((resolve) => (this._reqLockResolve = resolve));
104+
this.logger.emit('proxy:connect', { agent: this, req, opts });
104105
const agent = await super.connect(req, opts);
105106
// Work around https://github.com/TooTallNate/proxy-agents/pull/330
106107
if ('addRequest' in agent && typeof agent.addRequest === 'function') {
@@ -131,7 +132,10 @@ class DevtoolsProxyAgentWithSystemCA extends AgentBase {
131132
super();
132133
this.proxyOptions = proxyOptions;
133134
this.agent = (async () => {
134-
const { ca } = await systemCA({ ca: proxyOptions.ca });
135+
const { ca } = await systemCA({
136+
ca: proxyOptions.ca,
137+
excludeSystemCerts: proxyOptions.caExcludeSystemCerts,
138+
});
135139
return new DevtoolsProxyAgent(
136140
{ ...proxyOptions, ca, allowPartialTrustChain: true },
137141
this.logger,
@@ -167,8 +171,8 @@ export function useOrCreateAgent(
167171
target?: string,
168172
useTargetRegardlessOfExistingAgent = false,
169173
): AgentWithInitialize | undefined {
170-
if ('createConnection' in proxyOptions) {
171-
const agent = proxyOptions as AgentWithInitialize;
174+
if (isExistingAgentInstance(proxyOptions)) {
175+
const agent = proxyOptions;
172176
if (
173177
useTargetRegardlessOfExistingAgent &&
174178
target !== undefined &&
@@ -179,12 +183,15 @@ export function useOrCreateAgent(
179183
}
180184
return agent;
181185
} else {
182-
if (
183-
target !== undefined &&
184-
!proxyForUrl(proxyOptions as DevtoolsProxyOptions, target)
185-
) {
186+
if (target !== undefined && !proxyForUrl(proxyOptions, target)) {
186187
return undefined;
187188
}
188-
return createAgent(proxyOptions as DevtoolsProxyOptions);
189+
return createAgent(proxyOptions);
189190
}
190191
}
192+
193+
export function isExistingAgentInstance(
194+
options: DevtoolsProxyOptions | AgentWithInitialize,
195+
): options is AgentWithInitialize {
196+
return 'createConnection' in options;
197+
}

packages/devtools-proxy-support/src/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
export * from './proxy-options-public';
22
export { Tunnel, TunnelOptions, createSocks5Tunnel } from './socks5';
3-
export { createAgent, useOrCreateAgent, AgentWithInitialize } from './agent';
3+
export {
4+
createAgent,
5+
useOrCreateAgent,
6+
isExistingAgentInstance,
7+
AgentWithInitialize,
8+
} from './agent';
49
export {
510
createFetch,
611
Request,

packages/devtools-proxy-support/src/logging.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
// values used here; in particular,
44
// https://github.com/mongodb-js/compass/tree/55a5a608713d7316d158dc66febeb6b114d8b40d/packages/ssh-tunnel/src
55

6+
import type { AgentConnectOpts } from 'agent-base';
7+
import type { ClientRequest } from 'http';
8+
import type { SecureContextOptions } from 'tls';
9+
import type { AgentWithInitialize } from './agent';
10+
611
interface BaseSocks5RequestMetadata {
712
srcAddr: string;
813
srcPort: number;
@@ -45,6 +50,11 @@ export interface ProxyEventMap {
4550
retryableError: boolean;
4651
retriesLeft: number;
4752
}) => void;
53+
'proxy:connect': (ev: {
54+
agent: AgentWithInitialize;
55+
req: ClientRequest;
56+
opts: AgentConnectOpts & Partial<SecureContextOptions>;
57+
}) => void;
4858
}
4959

5060
export type ProxyEventArgs<K extends keyof ProxyEventMap> =

packages/devtools-proxy-support/src/proxy-options.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ export interface DevtoolsProxyOptions {
2121
};
2222

2323
// Not being honored by the translate-to-electron functionality
24-
// This will be merged with the system CA list and the Node.js built-in CA list
24+
// This will be merged with the system CA list and the Node.js built-in CA list,
25+
// unless `caExcludeSystemCerts` is set
2526
ca?: ConnectionOptions['ca'];
27+
// Exclude system certificates from the full certificate list.
28+
caExcludeSystemCerts?: boolean;
2629

2730
// Mostly intended for testing, defaults to `process.env`.
2831
// This should usually not be stored, and secrets will not be

packages/devtools-proxy-support/src/system-ca.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export async function systemCA(
5858
existingOptions: {
5959
ca?: NodeJSCAOption;
6060
tlsCAFile?: string | null | undefined;
61+
excludeSystemCerts?: boolean;
6162
} = {},
6263
): Promise<{
6364
ca: string;
@@ -81,9 +82,11 @@ export async function systemCA(
8182
const messages: string[] = [];
8283

8384
try {
84-
const systemCertsResult = await systemCertsCached();
85-
asyncFallbackError = systemCertsResult.asyncFallbackError;
86-
systemCerts = systemCertsResult.certs;
85+
if (!existingOptions.excludeSystemCerts) {
86+
const systemCertsResult = await systemCertsCached();
87+
asyncFallbackError = systemCertsResult.asyncFallbackError;
88+
systemCerts = systemCertsResult.certs;
89+
}
8790
} catch (err: any) {
8891
systemCertsError = err;
8992
}

0 commit comments

Comments
 (0)