Skip to content

Commit 98f6f31

Browse files
authored
fix(devtools-proxy-support): remove certificates without issuer from system CA list COMPASS-8252 (#467)
We have discovered that system certificate stores may contain certificates that a) OpenSSL may choose over other roots for the same certificate chain and b) are not accompanied by their own root certificate, i.e. only present an immediate certificate. While we will ideally solve this by using `X509_V_FLAG_PARTIAL_CHAIN`, Node.js does not expose that as a feature yet.
1 parent 7d2615b commit 98f6f31

File tree

6 files changed

+164
-7
lines changed

6 files changed

+164
-7
lines changed

packages/devtools-connect/src/connect.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -419,16 +419,22 @@ export async function connectMongoClient(
419419
detectAndLogMissingOptionalDependencies(logger);
420420

421421
try {
422-
const { ca, asyncFallbackError, systemCertsError, systemCACount } =
423-
await systemCA({
424-
ca: clientOptions.ca,
425-
tlsCAFile:
426-
clientOptions.tlsCAFile || getConnectionStringParam(uri, 'tlsCAFile'),
427-
});
422+
const {
423+
ca,
424+
asyncFallbackError,
425+
systemCertsError,
426+
systemCACount,
427+
messages,
428+
} = await systemCA({
429+
ca: clientOptions.ca,
430+
tlsCAFile:
431+
clientOptions.tlsCAFile || getConnectionStringParam(uri, 'tlsCAFile'),
432+
});
428433
logger.emit('devtools-connect:used-system-ca', {
429434
caCount: systemCACount,
430435
asyncFallbackError,
431436
systemCertsError,
437+
messages,
432438
});
433439

434440
// Create a proxy agent, if requested. `useOrCreateAgent()` takes a target argument

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ describe('Logging setup', function () {
7878
caCount: 1234,
7979
asyncFallbackError: new Error('had to fallback to sync'),
8080
systemCertsError: new Error('could not load system certs at all'),
81+
messages: ['A diagnostic message'],
8182
});
8283

8384
await log.flush();
@@ -212,6 +213,7 @@ describe('Logging setup', function () {
212213
caCount: 1234,
213214
asyncFallbackError: 'had to fallback to sync',
214215
systemCertsError: 'could not load system certs at all',
216+
messages: ['A diagnostic message'],
215217
},
216218
},
217219
]);

packages/devtools-connect/src/log-hook.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export function hookLogger(
169169
caCount: ev.caCount,
170170
asyncFallbackError: ev.asyncFallbackError?.message,
171171
systemCertsError: ev.systemCertsError?.message,
172+
messages: ev.messages,
172173
}
173174
);
174175
}

packages/devtools-connect/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface ConnectUsedSystemCAEvent {
5555
caCount: number;
5656
asyncFallbackError: Error | undefined;
5757
systemCertsError: Error | undefined;
58+
messages: string[];
5859
}
5960

6061
export interface ConnectEventMap
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { expect } from 'chai';
2+
import { removeCertificatesWithoutIssuer } from './system-ca';
3+
4+
describe('removeCertificatesWithoutIssuer', function () {
5+
it('removes certificates that do not have an issuer', function () {
6+
const certs = [
7+
`-----BEGIN CERTIFICATE-----
8+
MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAwTzELMAkG
9+
A1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2VhcmNoIEdyb3VwMRUw
10+
EwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4WhcNMzUwNjA0MTEwNDM4WjBP
11+
MQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJuZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3Jv
12+
dXAxFTATBgNVBAMTDElTUkcgUm9vdCBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
13+
ggIBAK3oJHP0FDfzm54rVygch77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj
14+
/RQSa78f0uoxmyF+0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7i
15+
S4+3mX6UA5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW
16+
T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyHB5T0Y3Hs
17+
LuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UCB5iPNgiV5+I3lg02
18+
dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUvKBds0pjBqAlkd25HN7rOrFle
19+
aJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWnOlFuhjuefXKnEgV4We0+UXgVCwOPjdAv
20+
BbI+e0ocS3MFEvzG6uBQE3xDk3SzynTnjh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymC
21+
zLq9gwQbooMDQaHWBfEbwrbwqHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC
22+
1CLQJ13hef4Y53CIrU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIB
23+
BjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq
24+
hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZLubhzEFnT
25+
IZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ3BebYhtF8GaV0nxv
26+
wuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KKNFtY2PwByVS5uCbMiogziUwt
27+
hDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5ORAzI4JMPJ+GslWYHb4phowim57iaztX
28+
OoJwTdwJx4nLCgdNbOhdjsnvzqvHu7UrTkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIu
29+
vtd7u+Nxe5AW0wdeRlN8NwdCjNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1N
30+
bdWhscdCb+ZAJzVcoyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4k
31+
qKOJ2qxq4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA
32+
mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57demyPxgcY
33+
xn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc=
34+
-----END CERTIFICATE-----`,
35+
`-----BEGIN CERTIFICATE-----
36+
MIIFYDCCBEigAwIBAgIQQAF3ITfU6UK47naqPGQKtzANBgkqhkiG9w0BAQsFADA/
37+
MSQwIgYDVQQKExtEaWdpdGFsIFNpZ25hdHVyZSBUcnVzdCBDby4xFzAVBgNVBAMT
38+
DkRTVCBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQwM1ow
39+
TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh
40+
cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwggIiMA0GCSqGSIb3DQEB
41+
AQUAA4ICDwAwggIKAoICAQCt6CRz9BQ385ueK1coHIe+3LffOJCMbjzmV6B493XC
42+
ov71am72AE8o295ohmxEk7axY/0UEmu/H9LqMZshftEzPLpI9d1537O4/xLxIZpL
43+
wYqGcWlKZmZsj348cL+tKSIG8+TA5oCu4kuPt5l+lAOf00eXfJlII1PoOK5PCm+D
44+
LtFJV4yAdLbaL9A4jXsDcCEbdfIwPPqPrt3aY6vrFk/CjhFLfs8L6P+1dy70sntK
45+
4EwSJQxwjQMpoOFTJOwT2e4ZvxCzSow/iaNhUd6shweU9GNx7C7ib1uYgeGJXDR5
46+
bHbvO5BieebbpJovJsXQEOEO3tkQjhb7t/eo98flAgeYjzYIlefiN5YNNnWe+w5y
47+
sR2bvAP5SQXYgd0FtCrWQemsAXaVCg/Y39W9Eh81LygXbNKYwagJZHduRze6zqxZ
48+
Xmidf3LWicUGQSk+WT7dJvUkyRGnWqNMQB9GoZm1pzpRboY7nn1ypxIFeFntPlF4
49+
FQsDj43QLwWyPntKHEtzBRL8xurgUBN8Q5N0s8p0544fAQjQMNRbcTa0B7rBMDBc
50+
SLeCO5imfWCKoqMpgsy6vYMEG6KDA0Gh1gXxG8K28Kh8hjtGqEgqiNx2mna/H2ql
51+
PRmP6zjzZN7IKw0KKP/32+IVQtQi0Cdd4Xn+GOdwiK1O5tmLOsbdJ1Fu/7xk9TND
52+
TwIDAQABo4IBRjCCAUIwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw
53+
SwYIKwYBBQUHAQEEPzA9MDsGCCsGAQUFBzAChi9odHRwOi8vYXBwcy5pZGVudHJ1
54+
c3QuY29tL3Jvb3RzL2RzdHJvb3RjYXgzLnA3YzAfBgNVHSMEGDAWgBTEp7Gkeyxx
55+
+tvhS5B1/8QVYIWJEDBUBgNVHSAETTBLMAgGBmeBDAECATA/BgsrBgEEAYLfEwEB
56+
ATAwMC4GCCsGAQUFBwIBFiJodHRwOi8vY3BzLnJvb3QteDEubGV0c2VuY3J5cHQu
57+
b3JnMDwGA1UdHwQ1MDMwMaAvoC2GK2h0dHA6Ly9jcmwuaWRlbnRydXN0LmNvbS9E
58+
U1RST09UQ0FYM0NSTC5jcmwwHQYDVR0OBBYEFHm0WeZ7tuXkAXOACIjIGlj26Ztu
59+
MA0GCSqGSIb3DQEBCwUAA4IBAQAKcwBslm7/DlLQrt2M51oGrS+o44+/yQoDFVDC
60+
5WxCu2+b9LRPwkSICHXM6webFGJueN7sJ7o5XPWioW5WlHAQU7G75K/QosMrAdSW
61+
9MUgNTP52GE24HGNtLi1qoJFlcDyqSMo59ahy2cI2qBDLKobkx/J3vWraV0T9VuG
62+
WCLKTVXkcGdtwlfFRjlBz4pYg1htmf5X6DYO8A4jqv2Il9DjXA6USbW1FzXSLr9O
63+
he8Y4IWS6wY7bCkjCWDcRQJMEhg76fsO3txE+FiYruq9RUWhiF1myv4Q6W+CyBFC
64+
Dfvp7OOGAN6dEOM4+qR9sdjoSYKEBpsr6GtPAQw4dy753ec5
65+
-----END CERTIFICATE-----`,
66+
];
67+
expect(removeCertificatesWithoutIssuer(certs)).to.deep.equal({
68+
ca: [certs[0]],
69+
messages: [
70+
`Removing certificate for 'C=US\nO=Internet Security Research Group\nCN=ISRG Root X1' because issuer 'O=Digital Signature Trust Co.\nCN=DST Root CA X3' could not be found (serial no '4001772137D4E942B8EE76AA3C640AB7')`,
71+
],
72+
});
73+
});
74+
});

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { systemCertsAsync } from 'system-ca';
22
import type { Options as SystemCAOptions } from 'system-ca';
33
import { promises as fs } from 'fs';
44
import { rootCertificates } from 'tls';
5+
import { X509Certificate } from 'crypto';
56

67
// A bit more generic than SecureContextOptions['ca'] because of Uint8Array -> Buffer + readonly
78
type NodeJSCAOption = string | Uint8Array | readonly (string | Uint8Array)[];
@@ -50,6 +51,63 @@ export function mergeCA(...args: (NodeJSCAOption | undefined)[]): string {
5051
return [...ca].join('\n');
5152
}
5253

54+
const pemWithParsedCache = new WeakMap<
55+
string[],
56+
{
57+
ca: string[];
58+
messages: string[];
59+
}
60+
>();
61+
// TODO(COMPASS-8253): Remove this in favor of OpenSSL's X509_V_FLAG_PARTIAL_CHAIN
62+
// See linked tickets for details on why we need this (tl;dr: the system certificate
63+
// store may contain intermediate certficiates without the corresponding trusted root,
64+
// and OpenSSL does not seem to accept that)
65+
export function removeCertificatesWithoutIssuer(ca: string[]): {
66+
ca: string[];
67+
messages: string[];
68+
} {
69+
let result:
70+
| {
71+
ca: string[];
72+
messages: string[];
73+
}
74+
| undefined = pemWithParsedCache.get(ca);
75+
76+
const messages: string[] = [];
77+
let caWithParsedCerts = ca.map((pem) => {
78+
let parsed: X509Certificate | null = null;
79+
try {
80+
parsed = new X509Certificate(pem);
81+
} catch (err: unknown) {
82+
messages.push(
83+
`Unable to parse certificate: ${
84+
err && typeof err === 'object' && 'message' in err
85+
? String(err.message)
86+
: String(err)
87+
}`
88+
);
89+
}
90+
return { pem, parsed };
91+
});
92+
caWithParsedCerts = caWithParsedCerts.filter(({ parsed }) => {
93+
const keep =
94+
!parsed ||
95+
parsed.checkIssued(parsed) ||
96+
caWithParsedCerts.find(
97+
({ parsed: issuer }) => issuer && parsed.checkIssued(issuer)
98+
);
99+
if (!keep) {
100+
messages.push(
101+
`Removing certificate for '${parsed.subject}' because issuer '${parsed.issuer}' could not be found (serial no '${parsed.serialNumber}')`
102+
);
103+
}
104+
return keep;
105+
});
106+
result = { ca: caWithParsedCerts.map(({ pem }) => pem), messages };
107+
pemWithParsedCache.set(ca, result);
108+
return result;
109+
}
110+
53111
// Thin wrapper around system-ca, which merges:
54112
// - Explicit CA options passed as options
55113
// - The Node.js TLS root store
@@ -58,12 +116,14 @@ export async function systemCA(
58116
existingOptions: {
59117
ca?: NodeJSCAOption;
60118
tlsCAFile?: string | null | undefined;
61-
} = {}
119+
} = {},
120+
allowCertificatesWithoutIssuer?: boolean // defaults to false
62121
): Promise<{
63122
ca: string;
64123
systemCACount: number;
65124
asyncFallbackError?: Error;
66125
systemCertsError?: Error;
126+
messages: string[];
67127
}> {
68128
let readTLSCAFilePromise: Promise<string> | undefined;
69129
if (existingOptions.tlsCAFile) {
@@ -76,13 +136,25 @@ export async function systemCA(
76136
let systemCertsError: Error | undefined;
77137
let asyncFallbackError: Error | undefined;
78138
let systemCerts: string[] = [];
139+
let messages: string[] = [];
79140

80141
try {
81142
({ certs: systemCerts, asyncFallbackError } = await systemCertsCached());
82143
} catch (err: any) {
83144
systemCertsError = err;
84145
}
85146

147+
if (
148+
!(
149+
allowCertificatesWithoutIssuer ??
150+
!!process.env.DEVTOOLS_ALLOW_CERTIFICATES_WITHOUT_ISSUER
151+
)
152+
) {
153+
const reducedList = removeCertificatesWithoutIssuer(systemCerts);
154+
systemCerts = reducedList.ca;
155+
messages = messages.concat(reducedList.messages);
156+
}
157+
86158
return {
87159
ca: mergeCA(
88160
systemCerts,
@@ -93,5 +165,6 @@ export async function systemCA(
93165
asyncFallbackError: asyncFallbackError,
94166
systemCertsError,
95167
systemCACount: systemCerts.length + rootCertificates.length,
168+
messages,
96169
};
97170
}

0 commit comments

Comments
 (0)