Raise nodemailer, body-parser, qs, uuid and postcss-selector-parser in the bundle.

A Grype scan of the image found advisories in packages that Meteor's own
packages carry in programs/server/npm/node_modules/meteor/<package>, which
package.json cannot reach.

nodemailer 8.0.11 -> 10.3.1: meteor/email only calls createTransport, the
'stream' plugin hook, nodemailer/lib/well-known and nodemailer/lib/mail-composer,
and all four work in 10.3.1 (checked with the bundle's own package specifiers,
through its exports map). body-parser 1.20.6/1.20.8 -> 1.20.9 and 2.3.0 ->
2.4.0: a minimum may now be "name@MAJOR", so each major is raised inside
itself. qs -> 6.16.1, postcss-selector-parser 6.1.4 -> 6.1.5.

uuid 8.3.2 and 9.0.1 -> 11.1.1: 11 moved its entry point, which is why it was
refused before. The bumper now writes the recorded dist/index.js back as an
ESM re-export of dist/esm/index.js, which require() loads and which leaves
uuid's own ESM and CJS entry points as they are.

glob 14 and minimatch 11 are already in package.json. Run against a copy of
the published image's bundle, the bumper raises all of these and every raised
copy loads.

Thanks to the Grype scan reporter and xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-11-04 14:45:04 +02:00
1 parent 3213d7fa1d
commit dad03959d9
3 files changed
+102 -55

No files matched your search

+19 -4
View File
@@ -155,14 +155,20 @@ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-bundle-bump-'));
const bumped = [];
const skipped = [];
for (const [name, minimum] of Object.entries(manifest.minimums)) {
const outdated = copiesOf(name).filter(c => compare(c.version, minimum) < 0);
for (const [key, minimum] of Object.entries(manifest.minimums)) {
// "body-parser@2" limits the minimum to copies of that major; "@babel/runtime"
// is a scoped name, not a major, so only an @ after the first character counts.
const at = key.lastIndexOf('@');
const name = at > 0 ? key.slice(0, at) : key;
const major = at > 0 ? key.slice(at + 1) : null;
const outdated = copiesOf(name).filter(c =>
(major === null || c.version.split('.')[0] === major) && compare(c.version, minimum) < 0);
if (!outdated.length) {
skipped.push(name);
skipped.push(key);
continue;
}
const prefix = path.join(tmpRoot, name.replace('/', '+'));
const prefix = path.join(tmpRoot, key.replace(/[\/@]/g, '+') + '-' + minimum);
if (!dryRun) {
fs.mkdirSync(prefix, { recursive: true });
// A prefix with no package.json is what we want: npm installs the one
@@ -180,6 +186,15 @@ for (const [name, minimum] of Object.entries(manifest.minimums)) {
fs.rmSync(copy.dir, { recursive: true, force: true });
fs.cpSync(path.join(prefix, 'node_modules', name), copy.dir, { recursive: true });
// The entry file the bundle recorded for the OLD version, written back so it
// resolves again (uuid 11 moved dist/index.js to dist/cjs/index.js).
for (const [rel, body] of Object.entries((manifest.shims || {})[name] || {})) {
const file = path.join(copy.dir, rel);
if (fs.existsSync(file)) continue;
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, body);
console.log(` + ${rel} (shim for the entry point ${name} ${copy.version} was built against)`);
}
// Whatever the new version needs that this node_modules does not have. An
// existing sibling is left as it is - it is some other package's choice.
+30 -32
View File
@@ -7,10 +7,20 @@
"be raised is the bundle, after `meteor build` has produced it.",
"",
"Every entry is a version that FIXES a published advisory in the version the",
"bundle would otherwise ship, and stays INSIDE THE SAME MAJOR. That is not",
"caution for its own sake: a Meteor bundle records the file a package resolves",
"to when it is built, so a major that moves its entry point cannot be swapped",
"in afterwards - see uuid under notFixable, which is what taught us that.",
"bundle would otherwise ship. A minimum stays inside the same major WHEN a fix",
"exists there. When the fix is only in a newer major it is still taken, but only",
"after the new major was run the way the Meteor package uses it: nodemailer 10",
"against the calls meteor/email makes, uuid 11 through a shim (see \"shims\").",
"",
"A key may be \"name@MAJOR\" (body-parser@2): the minimum then applies only to",
"copies of that major, so body-parser 1.x and 2.x are each raised inside their",
"own major and neither is dragged across it.",
"",
"A bundle records the file a package resolves to when it is built, so a major",
"that moved its entry point cannot just be swapped in: uuid 11 moved it from",
"dist/index.js to dist/cjs/index.js and the server died on boot (\"Cannot find",
"module .../uuid/dist/index.js\"). \"shims\" writes the old entry file back,",
"re-exporting the new one.",
"",
"Applied by releases/bump-bundle-npm-deps.mjs, pinned by",
"tests/bundleNpmSecurityBumps.test.cjs. A package NOT listed here is a package",
@@ -21,7 +31,8 @@
"minimums": {
"@babel/runtime": "7.29.7",
"baseline-browser-mapping": "2.11.0",
"body-parser": "1.20.6",
"body-parser": "1.20.9",
"body-parser@2": "2.4.0",
"browserslist": "4.28.7",
"colord": "2.9.4",
"compression": "1.8.2",
@@ -30,43 +41,30 @@
"lodash": "4.18.1",
"lodash.template": "4.18.1",
"nanoid": "3.3.18",
"nodemailer": "8.0.11",
"nodemailer": "10.3.1",
"on-headers": "1.1.0",
"openpgp": "5.11.3",
"picomatch": "4.0.5",
"postcss": "8.5.26",
"qs": "6.16.0",
"postcss-selector-parser": "6.1.5",
"qs": "6.16.1",
"source-map-js": "1.2.2",
"svgo": "2.8.4",
"tmp": "0.2.7",
"underscore": "1.13.8"
"underscore": "1.13.8",
"uuid": "11.1.1"
},
"notFixable": {
"nodemailer": [
"8.0.11 is the newest 8.x. CVE-2026-82659 (fixed in 9.0.1), GHSA-2x7j-588g-ccc2,",
"GHSA-cc9r-2j5m-2m83 and GHSA-wmmp-3585-3rmp (9.1.0), GHSA-8m3c-c648-2xjj (9.1.1),",
"GHSA-6vj9-mwq6-2f5v and GHSA-8vvx-rff5-p5rq (10.0.2) and GHSA-v53p-9fqp-m79j",
"(10.0.6) are fixed only in majors the Meteor email package is not written",
"against. They move when meteor/email moves."
],
"postcss-selector-parser": [
"CVE-2026-104844 in the 6.1.4 meteor/minifier-css brings (through cssnano) is",
"fixed only in 7.1.6, a new major its dependents are not written against.",
"minifier-css runs at build time; it moves when Meteor's minifier-css moves."
],
"uuid": [
"CVE-2026-41907 affects the 8.3.2 in meteor/ddp-server and",
"meteor/inter-process-messaging and the 9.0.1 ldapts brings to wekan-ldap,",
"and the lowest fixed release is 11.1.1 - a major that MOVED its entry",
"point from dist/index.js to dist/cjs/index.js. The bundle resolves the old",
"path, so putting 11.1.1 in its place stops the server before it starts:",
"",
" error on boot.js Error: Cannot find module",
" \"/node_modules/meteor/inter-process-messaging/node_modules/uuid/dist/index.js\"",
"",
"It was tried, it failed exactly there, and it is left at the version the",
"packages were built against. It moves when those packages move: Meteor's",
"for ddp-server and inter-process-messaging, ldapts' for wekan-ldap."
"6.1.5 fixes GHSA-rj75-hqrm-r3gf and is taken. CVE-2026-104844 in the copy",
"meteor/minifier-css brings (through cssnano) is fixed only in 7.1.6, a new major",
"its dependents are not written against. minifier-css runs at build time; it",
"moves when Meteor's minifier-css moves."
]
},
"shims": {
"uuid": {
"dist/index.js": "export * from './esm/index.js';\n"
}
}
}
+53 -19
View File
@@ -41,8 +41,9 @@ console.log('bundleNpmSecurityBumps:');
test('the manifest lists the packages the scan found, at fixed versions', () => {
const expected = {
'nodemailer': '8.0', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16', 'body-parser': '1.20',
'nodemailer': '10.3', 'openpgp': '5.11', 'svgo': '2.8.4', 'postcss': '8.5',
'nanoid': '3.3', 'lodash': '4.18', 'qs': '6.16.1', 'body-parser': '1.20.9', 'body-parser@2': '2.4',
'uuid': '11.1.1', 'postcss-selector-parser': '6.1.5',
'cookie': '0.7', 'on-headers': '1.1', 'tmp': '0.2', 'diff': '3.5',
'@babel/runtime': '7.', 'underscore': '1.13.8',
// Artifact Hub's Trivy report of the v12.38 image: same-major fixes.
@@ -57,25 +58,58 @@ test('the manifest lists the packages the scan found, at fixed versions', () =>
}
});
test('uuid is NOT bumped, and says why (negative)', () => {
// The one that was tried and reverted. If somebody adds it back, the bundle
// stops booting - so the reason lives where they would look.
assert.ok(!('uuid' in manifest.minimums), 'uuid must not be in minimums');
const reason = (manifest.notFixable.uuid || []).join(' ');
assert.ok(/Cannot find module/.test(reason) && /dist\/cjs\/index\.js/.test(reason),
'and notFixable carries the boot error it caused');
test('uuid is bumped to 11.1.1 and the entry point the bundle recorded is written back', () => {
// uuid 11 moved its entry from dist/index.js to dist/cjs/index.js, and a bundle
// that recorded dist/index.js died on boot ("Cannot find module
// .../uuid/dist/index.js"). The fix is a shim at the old path, not a refusal.
assert.strictEqual(manifest.minimums.uuid, '11.1.1');
const shim = (manifest.shims || {}).uuid || {};
assert.strictEqual(shim['dist/index.js'], "export * from './esm/index.js';\n",
'dist/index.js re-exports the ESM build (uuid is "type": "module"; a CJS shim would be parsed as ESM)');
assert.ok(/manifest\.shims/.test(bumper) && /writeFileSync\(file, body\)/.test(bumper),
'and the bumper writes it after every replacement');
// negative: nothing is described as unfixable that was fixed
for (const name of ['uuid', 'nodemailer']) {
assert.ok(!(name in manifest.notFixable), `${name} must not stay in notFixable`);
}
});
test('a fix that needs a new major is recorded in notFixable, never in minimums (negative)', () => {
for (const name of ['nodemailer', 'postcss-selector-parser', 'uuid']) {
const reason = (manifest.notFixable[name] || []).join(' ');
assert.ok(/major/.test(reason), `${name} says the fix is a new major`);
}
// nodemailer stays at the newest 8.x; the 9.x/10.x fixes are named, not taken.
assert.ok(manifest.minimums.nodemailer.startsWith('8.'));
assert.ok(!('postcss-selector-parser' in manifest.minimums));
// lodash.template has had a fixed release (4.18.x) since 2026-04; it is no
// longer described as unfixable.
test('body-parser 1.x and 2.x are each raised inside their own major', () => {
assert.strictEqual(manifest.minimums['body-parser'], '1.20.9');
assert.strictEqual(manifest.minimums['body-parser@2'], '2.4.0');
const fs2 = require('fs'), os = require('os'), cp = require('child_process');
const bundle = fs2.mkdtempSync(path.join(process.env.TMPDIR || os.tmpdir(), 'bump-test-'));
const put = (dir, version) => {
const d = path.join(bundle, 'programs/server/npm/node_modules', dir);
fs2.mkdirSync(d, { recursive: true });
fs2.writeFileSync(path.join(d, 'package.json'), JSON.stringify({ name: 'x', version }));
};
put('meteor/a/node_modules/body-parser', '1.20.6');
put('meteor/b/node_modules/body-parser', '2.3.0');
put('meteor/c/node_modules/body-parser', '2.4.0');
put('meteor/d/node_modules/body-parser', '1.20.9');
put('meteor/e/node_modules/uuid', '8.3.2');
put('meteor/f/node_modules/uuid', '14.0.2');
put('meteor/g/node_modules/nodemailer', '8.0.11');
const out = cp.execFileSync(process.execPath,
[path.join(repoRoot, 'releases/bump-bundle-npm-deps.mjs'), bundle, '--dry-run'], { encoding: 'utf8' });
fs2.rmSync(bundle, { recursive: true, force: true });
const lines = out.split('\n').filter(l => /->/.test(l));
const has = re => lines.some(l => re.test(l));
assert.ok(has(/body-parser 1\.20\.6 -> 1\.20\.9/), '1.x below 1.20.9 is raised to 1.20.9');
assert.ok(has(/body-parser 2\.3\.0 -> 2\.4\.0/), '2.3.0 is raised to 2.4.0');
// negative: neither crosses into the other major, and fixed copies are left alone
assert.ok(!has(/body-parser 1\.\d+\.\d+ -> 2\./) && !has(/body-parser 2\.\d+\.\d+ -> 1\./));
assert.ok(!has(/body-parser 2\.4\.0 ->/) && !has(/body-parser 1\.20\.9 ->/));
assert.ok(has(/uuid 8\.3\.2 -> 11\.1\.1/) && !has(/uuid 14\./), 'uuid 14 is above the minimum');
assert.ok(has(/nodemailer 8\.0\.11 -> 10\.3\.1/));
});
test('a fix that needs a new major its dependents cannot take is recorded in notFixable (negative)', () => {
const reason = (manifest.notFixable['postcss-selector-parser'] || []).join(' ');
assert.ok(/major/.test(reason) && /CVE-2026-104844/.test(reason));
assert.ok(manifest.minimums['postcss-selector-parser'] === '6.1.5',
'but the same-major fix that exists is taken');
assert.ok(!('lodash.template' in manifest.notFixable));
for (const [name, version] of Object.entries(manifest.minimums)) {
assert.ok(/^\d+\.\d+\.\d+$/.test(version), `${name} is pinned to an exact version`);