@launchdarkly/mcp-server 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -9
- package/bin/mcp-server.js +145 -5
- package/bin/mcp-server.js.map +8 -7
- package/dist/commonjs/lib/config.d.ts +2 -2
- package/dist/commonjs/lib/config.js +2 -2
- package/dist/commonjs/mcp-server/cli/start/impl.d.ts.map +1 -1
- package/dist/commonjs/mcp-server/cli/start/impl.js +7 -0
- package/dist/commonjs/mcp-server/cli/start/impl.js.map +1 -1
- package/dist/commonjs/mcp-server/cli/start/tls-blocklist.d.ts +48 -0
- package/dist/commonjs/mcp-server/cli/start/tls-blocklist.d.ts.map +1 -0
- package/dist/commonjs/mcp-server/cli/start/tls-blocklist.js +206 -0
- package/dist/commonjs/mcp-server/cli/start/tls-blocklist.js.map +1 -0
- package/dist/commonjs/mcp-server/mcp-server.js +1 -1
- package/dist/commonjs/mcp-server/server.js +1 -1
- package/dist/esm/lib/config.d.ts +2 -2
- package/dist/esm/lib/config.js +2 -2
- package/dist/esm/mcp-server/cli/start/impl.d.ts.map +1 -1
- package/dist/esm/mcp-server/cli/start/impl.js +7 -0
- package/dist/esm/mcp-server/cli/start/impl.js.map +1 -1
- package/dist/esm/mcp-server/cli/start/tls-blocklist.d.ts +48 -0
- package/dist/esm/mcp-server/cli/start/tls-blocklist.d.ts.map +1 -0
- package/dist/esm/mcp-server/cli/start/tls-blocklist.js +192 -0
- package/dist/esm/mcp-server/cli/start/tls-blocklist.js.map +1 -0
- package/dist/esm/mcp-server/mcp-server.js +1 -1
- package/dist/esm/mcp-server/server.js +1 -1
- package/jsr.json +1 -1
- package/package.json +2 -1
- package/src/lib/config.ts +2 -2
- package/src/mcp-server/cli/start/impl.ts +11 -0
- package/src/mcp-server/cli/start/tls-blocklist.ts +252 -0
- package/src/mcp-server/mcp-server.ts +1 -1
- package/src/mcp-server/server.ts +1 -1
- package/test/tls-blocklist.test.mjs +314 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFile, execFileSync, spawnSync } from "node:child_process";
|
|
3
|
+
import { X509Certificate } from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import https from "node:https";
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import tls from "node:tls";
|
|
10
|
+
import { after, before, describe, test } from "node:test";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import {
|
|
13
|
+
BLOCKED_CERT_ERROR_CODE,
|
|
14
|
+
BLOCKED_SPKI_SHA256,
|
|
15
|
+
checkServerIdentity,
|
|
16
|
+
isNodeVersionPatched,
|
|
17
|
+
spkiSha256,
|
|
18
|
+
unpatchedRuntimeWarning,
|
|
19
|
+
} from "../dist/esm/mcp-server/cli/start/tls-blocklist.js";
|
|
20
|
+
|
|
21
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const blocklistModule = path.join(
|
|
23
|
+
here,
|
|
24
|
+
"../dist/esm/mcp-server/cli/start/tls-blocklist.js",
|
|
25
|
+
);
|
|
26
|
+
// From https://emudhra.we-have-a-problem.com/impacted-intermediates.json
|
|
27
|
+
const impacted = JSON.parse(
|
|
28
|
+
fs.readFileSync(
|
|
29
|
+
path.join(here, "fixtures/emudhra-impacted-intermediates.json"),
|
|
30
|
+
"utf8",
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
// app.launchdarkly.com leaf + GlobalSign Atlas intermediate, captured 2026-10-21.
|
|
34
|
+
const ldChainPems = fs
|
|
35
|
+
.readFileSync(
|
|
36
|
+
path.join(here, "fixtures/app-launchdarkly-com-chain.pem"),
|
|
37
|
+
"utf8",
|
|
38
|
+
)
|
|
39
|
+
.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);
|
|
40
|
+
|
|
41
|
+
/** Builds a tls.PeerCertificate-shaped chain (leaf first) from PEMs. */
|
|
42
|
+
function peerChain(pems) {
|
|
43
|
+
const certs = pems.map((pem) => {
|
|
44
|
+
const x = new X509Certificate(pem);
|
|
45
|
+
const name = (dn) =>
|
|
46
|
+
Object.fromEntries(
|
|
47
|
+
dn.split("\n").map((l) => l.split(/=(.*)/s).slice(0, 2)),
|
|
48
|
+
);
|
|
49
|
+
return {
|
|
50
|
+
raw: x.raw,
|
|
51
|
+
fingerprint256: x.fingerprint256,
|
|
52
|
+
subject: name(x.subject),
|
|
53
|
+
issuer: name(x.issuer),
|
|
54
|
+
subjectaltname: x.subjectAltName,
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
certs.forEach((c, i) => {
|
|
58
|
+
c.issuerCertificate = certs[i + 1] ?? c;
|
|
59
|
+
});
|
|
60
|
+
return certs[0];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const globalSignR3 = tls.rootCertificates.find((pem) =>
|
|
64
|
+
new X509Certificate(pem).subject.includes("OU=GlobalSign Root CA - R3"),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
test("the fixture covers 16 certificates over the 8 blocked keys", () => {
|
|
68
|
+
assert.equal(impacted.length, 16);
|
|
69
|
+
assert.deepEqual(
|
|
70
|
+
new Set(impacted.map((c) => c.spki_sha256.toLowerCase())),
|
|
71
|
+
BLOCKED_SPKI_SHA256,
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("checkServerIdentity", () => {
|
|
76
|
+
for (const cert of impacted) {
|
|
77
|
+
const label = `${cert.name} (issued by ${cert.issued_by})`;
|
|
78
|
+
|
|
79
|
+
test(`blocks ${label} as a chain element`, () => {
|
|
80
|
+
assert.equal(
|
|
81
|
+
spkiSha256(new X509Certificate(cert.pem).raw),
|
|
82
|
+
cert.spki_sha256.toLowerCase(),
|
|
83
|
+
);
|
|
84
|
+
// A leaf whose issuer name is benign, so only the key hash can catch it.
|
|
85
|
+
const chain = peerChain([ldChainPems[0], cert.pem, globalSignR3]);
|
|
86
|
+
chain.issuer = { CN: "Some Other CA" };
|
|
87
|
+
const err = checkServerIdentity("app.launchdarkly.com", chain);
|
|
88
|
+
assert.ok(err instanceof Error);
|
|
89
|
+
assert.equal(err.code, BLOCKED_CERT_ERROR_CODE);
|
|
90
|
+
assert.match(err.message, /blocked public key/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test(`blocks a leaf issued by ${label} when only the leaf is visible`, () => {
|
|
94
|
+
// Bun's fetch passes only the leaf to checkServerIdentity.
|
|
95
|
+
const leaf = peerChain([ldChainPems[0]]);
|
|
96
|
+
const cn = new X509Certificate(cert.pem).subject.match(/CN=(.*)/)[1];
|
|
97
|
+
leaf.issuer = { C: "NL", O: "eMudhra BV", CN: cn };
|
|
98
|
+
delete leaf.issuerCertificate;
|
|
99
|
+
const err = checkServerIdentity("app.launchdarkly.com", leaf);
|
|
100
|
+
assert.equal(err?.code, BLOCKED_CERT_ERROR_CODE);
|
|
101
|
+
assert.match(err.message, /issued by/);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test("blocks issuer names regardless of case and spacing", () => {
|
|
106
|
+
const leaf = peerChain([ldChainPems[0]]);
|
|
107
|
+
leaf.issuer = { CN: " em dv tls CA - g3B " };
|
|
108
|
+
delete leaf.issuerCertificate;
|
|
109
|
+
assert.equal(
|
|
110
|
+
checkServerIdentity("app.launchdarkly.com", leaf)?.code,
|
|
111
|
+
BLOCKED_CERT_ERROR_CODE,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("allows the real app.launchdarkly.com chain", () => {
|
|
116
|
+
assert.ok(globalSignR3, "GlobalSign Root CA - R3 is in the bundled roots");
|
|
117
|
+
const chain = peerChain([...ldChainPems, globalSignR3]);
|
|
118
|
+
assert.equal(checkServerIdentity("app.launchdarkly.com", chain), undefined);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("still enforces the hostname", () => {
|
|
122
|
+
const chain = peerChain([...ldChainPems, globalSignR3]);
|
|
123
|
+
const err = checkServerIdentity("evil.example.com", chain);
|
|
124
|
+
assert.equal(err?.code, "ERR_TLS_CERT_ALTNAME_INVALID");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("unpatchedRuntimeWarning", () => {
|
|
129
|
+
const cases = [
|
|
130
|
+
["22.23.3", false],
|
|
131
|
+
["22.23.4", true],
|
|
132
|
+
["24.2.0", false],
|
|
133
|
+
["24.22.0", false],
|
|
134
|
+
["24.22.1", true],
|
|
135
|
+
["24.23.0", true],
|
|
136
|
+
["20.19.5", false],
|
|
137
|
+
["23.11.1", false],
|
|
138
|
+
["25.9.0", false],
|
|
139
|
+
["26.12.0", false],
|
|
140
|
+
["26.12.1", true],
|
|
141
|
+
["27.0.0", true],
|
|
142
|
+
];
|
|
143
|
+
for (const [version, patched] of cases) {
|
|
144
|
+
test(`Node.js ${version} is ${patched ? "patched" : "unpatched"}`, () => {
|
|
145
|
+
assert.equal(isNodeVersionPatched(version), patched);
|
|
146
|
+
assert.equal(
|
|
147
|
+
unpatchedRuntimeWarning({ node: version }) === undefined,
|
|
148
|
+
patched,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test("always warns on Bun until a fixed release is recorded", () => {
|
|
154
|
+
assert.match(
|
|
155
|
+
unpatchedRuntimeWarning({ node: "24.3.0", bun: "1.4.3" }),
|
|
156
|
+
/Bun 1\.4\.3/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// End to end: a real TLS handshake through a chain whose intermediate carries
|
|
162
|
+
// a blocked eMudhra name (with our own key, since nobody has the real ones).
|
|
163
|
+
const haveOpenSSL = spawnSync("openssl", ["version"]).status === 0;
|
|
164
|
+
describe(
|
|
165
|
+
"installTlsBlocklist with global fetch",
|
|
166
|
+
{ skip: !haveOpenSSL && "openssl not available" },
|
|
167
|
+
() => {
|
|
168
|
+
let dir;
|
|
169
|
+
const servers = [];
|
|
170
|
+
const urls = {};
|
|
171
|
+
|
|
172
|
+
function openssl(...args) {
|
|
173
|
+
execFileSync("openssl", args, { cwd: dir, stdio: "ignore" });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function issue(name, subject, issuer, ext) {
|
|
177
|
+
openssl(
|
|
178
|
+
"req",
|
|
179
|
+
"-new",
|
|
180
|
+
"-newkey",
|
|
181
|
+
"ec",
|
|
182
|
+
"-pkeyopt",
|
|
183
|
+
"ec_paramgen_curve:P-256",
|
|
184
|
+
"-nodes",
|
|
185
|
+
"-keyout",
|
|
186
|
+
`${name}.key`,
|
|
187
|
+
"-out",
|
|
188
|
+
`${name}.csr`,
|
|
189
|
+
"-subj",
|
|
190
|
+
subject,
|
|
191
|
+
);
|
|
192
|
+
fs.writeFileSync(path.join(dir, `${name}.ext`), ext);
|
|
193
|
+
openssl(
|
|
194
|
+
"x509",
|
|
195
|
+
"-req",
|
|
196
|
+
"-in",
|
|
197
|
+
`${name}.csr`,
|
|
198
|
+
"-CA",
|
|
199
|
+
`${issuer}.pem`,
|
|
200
|
+
"-CAkey",
|
|
201
|
+
`${issuer}.key`,
|
|
202
|
+
"-CAcreateserial",
|
|
203
|
+
"-days",
|
|
204
|
+
"2",
|
|
205
|
+
"-out",
|
|
206
|
+
`${name}.pem`,
|
|
207
|
+
"-extfile",
|
|
208
|
+
`${name}.ext`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function serve(label, intermediate) {
|
|
213
|
+
const read = (f) => fs.readFileSync(path.join(dir, f));
|
|
214
|
+
const server = https.createServer(
|
|
215
|
+
{
|
|
216
|
+
key: read(`${label}-leaf.key`),
|
|
217
|
+
cert: Buffer.concat([
|
|
218
|
+
read(`${label}-leaf.pem`),
|
|
219
|
+
read(`${intermediate}.pem`),
|
|
220
|
+
]),
|
|
221
|
+
},
|
|
222
|
+
(_req, res) => res.end("ok"),
|
|
223
|
+
);
|
|
224
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
225
|
+
servers.push(server);
|
|
226
|
+
urls[label] = `https://localhost:${server.address().port}/`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
before(async () => {
|
|
230
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "tls-blocklist-"));
|
|
231
|
+
openssl(
|
|
232
|
+
"req",
|
|
233
|
+
"-x509",
|
|
234
|
+
"-newkey",
|
|
235
|
+
"ec",
|
|
236
|
+
"-pkeyopt",
|
|
237
|
+
"ec_paramgen_curve:P-256",
|
|
238
|
+
"-nodes",
|
|
239
|
+
"-keyout",
|
|
240
|
+
"root.key",
|
|
241
|
+
"-out",
|
|
242
|
+
"root.pem",
|
|
243
|
+
"-days",
|
|
244
|
+
"2",
|
|
245
|
+
"-subj",
|
|
246
|
+
"/CN=Test Root",
|
|
247
|
+
"-addext",
|
|
248
|
+
"basicConstraints=critical,CA:TRUE",
|
|
249
|
+
"-addext",
|
|
250
|
+
"keyUsage=critical,keyCertSign,cRLSign",
|
|
251
|
+
);
|
|
252
|
+
const caExt =
|
|
253
|
+
"basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n";
|
|
254
|
+
const leafExt =
|
|
255
|
+
"basicConstraints=critical,CA:FALSE\nsubjectAltName=DNS:localhost\nextendedKeyUsage=serverAuth\n";
|
|
256
|
+
issue(
|
|
257
|
+
"emudhra-int",
|
|
258
|
+
"/C=NL/O=eMudhra BV/CN=EM DV TLS CA - G3B",
|
|
259
|
+
"root",
|
|
260
|
+
caExt,
|
|
261
|
+
);
|
|
262
|
+
issue("benign-int", "/CN=Test Intermediate", "root", caExt);
|
|
263
|
+
issue("emudhra-leaf", "/CN=localhost", "emudhra-int", leafExt);
|
|
264
|
+
issue("benign-leaf", "/CN=localhost", "benign-int", leafExt);
|
|
265
|
+
await serve("emudhra", "emudhra-int");
|
|
266
|
+
await serve("benign", "benign-int");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
after(() => {
|
|
270
|
+
servers.forEach((s) => s.close());
|
|
271
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// Each case runs in a child process so the global patch and the extra CA
|
|
275
|
+
// stay out of this test process.
|
|
276
|
+
function fetchInChild(url, install) {
|
|
277
|
+
const script = `
|
|
278
|
+
const { installTlsBlocklist } = await import(${JSON.stringify(blocklistModule)});
|
|
279
|
+
if (${install}) installTlsBlocklist();
|
|
280
|
+
try { const r = await fetch(${JSON.stringify(url)}); console.log("OK " + r.status); }
|
|
281
|
+
catch (e) { console.log("FAIL " + (e.cause?.code ?? e.code ?? e.message)); }`;
|
|
282
|
+
// Async, so this process can keep serving the handshake.
|
|
283
|
+
return new Promise((resolve, reject) => {
|
|
284
|
+
execFile(
|
|
285
|
+
process.execPath,
|
|
286
|
+
["--input-type=module", "-e", script],
|
|
287
|
+
{
|
|
288
|
+
env: {
|
|
289
|
+
...process.env,
|
|
290
|
+
NODE_EXTRA_CA_CERTS: path.join(dir, "root.pem"),
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
(err, stdout) => (err ? reject(err) : resolve(stdout.trim())),
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
test("rejects a chain through a blocked eMudhra intermediate", async () => {
|
|
299
|
+
assert.equal(
|
|
300
|
+
await fetchInChild(urls.emudhra, false),
|
|
301
|
+
"OK 200",
|
|
302
|
+
"control: the chain is otherwise valid",
|
|
303
|
+
);
|
|
304
|
+
assert.equal(
|
|
305
|
+
await fetchInChild(urls.emudhra, true),
|
|
306
|
+
`FAIL ${BLOCKED_CERT_ERROR_CODE}`,
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("allows a chain through any other intermediate", async () => {
|
|
311
|
+
assert.equal(await fetchInChild(urls.benign, true), "OK 200");
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
);
|