Clear the high-severity advisories that reach production

npm audit reports 8 high findings. Four of them reach production; the other four are
dev-only and cannot, because prod installs with --omit=dev. Verified rather than
assumed: puppeteer-core, extract-zip, @puppeteer/browsers and js-yaml are all absent
from prod's node_modules.

Three of the four are transitive, and the fix is a patch or minor inside the range
package.json already declares — no API moves, and package.json is untouched by them:

  brace-expansion    2.1.2  -> 2.1.4    (archiver -> glob/minimatch)
  ip-address         10.2.0 -> 10.5.0   (express-rate-limit)
  socket.io-parser   4.2.6  -> 4.2.7    (socket.io)

socket.io-parser was the one worth checking, because a parser change that altered the
wire format would break every deployed player at once rather than fail a test. It does
not: socket.io stays at 4.8.3, engine.io at 6.6.9, and the parser's protocol constant
is still 5. Nothing a player speaks changes.

The fourth is a real bump — nodemailer 6.10.1 -> 9.0.5, across three majors, closing
eight advisories including SMTP command injection and header injection. Our surface is
about as small as it gets: createTransport({host, port, secure, auth}) and sendMail with
from/to/subject/text/html. Engine requirements are unchanged (>=6.0.0), and the entry
point is the same.

The existing email tests mock nodemailer through require.cache, so they would have
stayed green through any breaking change in the library itself — proven, not guessed:
with sendMail patched to throw, those 15 tests still pass. So this adds a test that
drives the REAL library over a loopback SMTP server and asserts on the conversation,
using messages built by our own buildSmtpMessage rather than hand-written ones. That
test does fail against the broken build.

Left alone: extract-zip under puppeteer-core, now with no fix available. It is a
devDependency used only by smoke-ui.js, which already no-ops when it is missing, and the
advisory is symlink traversal while unpacking a downloaded browser — puppeteer-core with
an explicit executablePath never downloads or extracts one.

Production audit goes from 4 high to 0. 1671/1671 pass.
This commit is contained in:
ScreenTinker 2026-08-14 09:43:04 -05:00
parent 702e107972
commit dce63e4050
3 changed files with 111 additions and 17 deletions

View file

@ -21,7 +21,7 @@
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"nodemailer": "^9.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"socket.io": "^4.7.2",
@ -1941,9 +1941,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@ -3434,9 +3434,9 @@
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
@ -3544,9 +3544,9 @@
"license": "BSD-3-Clause"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"dev": true,
"funding": [
{
@ -3913,9 +3913,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@ -4931,9 +4931,9 @@
"license": "MIT"
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"version": "4.2.7",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz",
"integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",

View file

@ -23,7 +23,7 @@
"jimp": "^1.6.1",
"jsonwebtoken": "^9.0.3",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"nodemailer": "^9.0.5",
"otplib": "^12.0.1",
"qrcode": "^1.5.4",
"socket.io": "^4.7.2",

View file

@ -0,0 +1,94 @@
// The rest of the email tests mock nodemailer through require.cache, which proves our code
// calls sendMail correctly but says nothing about whether nodemailer still ACCEPTS what we
// hand it. That gap is exactly where a dependency bump breaks sending: the suite stays green
// while mail stops leaving the building.
//
// This drives the real library against a throwaway SMTP server on a loopback port, using the
// option and message shapes services/email.js actually builds. No network, no credentials.
const test = require('node:test');
const assert = require('node:assert');
const net = require('node:net');
const nodemailer = require('nodemailer');
const { buildSmtpMessage } = require('../services/email');
// A minimal SMTP server that answers enough of the protocol to accept one message and
// records the conversation, so assertions can be made about what went over the wire.
function startFakeSmtp() {
const seen = { commands: [], data: '' };
return new Promise((resolve) => {
const srv = net.createServer((sock) => {
let inData = false;
sock.write('220 fake.local ESMTP\r\n');
sock.on('data', (buf) => {
const chunk = buf.toString();
if (inData) {
seen.data += chunk;
if (/\r\n\.\r\n/.test(seen.data)) { inData = false; sock.write('250 OK queued\r\n'); }
return;
}
for (const line of chunk.split('\r\n').filter(Boolean)) {
seen.commands.push(line);
const cmd = line.split(' ')[0].toUpperCase();
if (cmd === 'EHLO' || cmd === 'HELO') sock.write('250-fake.local\r\n250 SIZE 10485760\r\n');
else if (cmd === 'MAIL' || cmd === 'RCPT') sock.write('250 OK\r\n');
else if (cmd === 'DATA') { inData = true; sock.write('354 send it\r\n'); }
else if (cmd === 'QUIT') { sock.write('221 bye\r\n'); sock.end(); }
else sock.write('250 OK\r\n');
}
});
sock.on('error', () => {}); // a client hanging up mid-conversation is not a test failure
});
srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen }));
});
}
test('the installed nodemailer accepts the options and messages we build', async () => {
const { srv, port, seen } = await startFakeSmtp();
try {
// The shape getSmtpTransporter() constructs.
const transporter = nodemailer.createTransport({
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
});
// The object `from` form, used when a display name overrides the configured one.
await transporter.sendMail({
from: { name: 'ScreenTinker', address: 'noreply@example.com' },
to: 'user@x.com',
subject: '[ScreenTinker] Hello',
html: '<p>hi there</p>',
text: 'hi there',
});
assert.ok(seen.commands.some(c => /^EHLO/i.test(c)), 'greets the server');
assert.ok(seen.commands.some(c => /^MAIL FROM:<noreply@example\.com>/i.test(c)),
'envelope sender is the configured address, not the display name');
assert.ok(seen.commands.some(c => /^RCPT TO:<user@x\.com>/i.test(c)), 'envelope recipient');
assert.match(seen.data, /Subject: \[ScreenTinker\] Hello/, 'subject and its prefix survive encoding');
assert.match(seen.data, /From: ScreenTinker <noreply@example\.com>/, 'display-name form still renders');
assert.match(seen.data, /Content-Type: multipart\/alternative/, 'text and html sent as alternatives');
// The string `from` form, used when there is no override.
await transporter.sendMail({
from: 'ScreenTinker <noreply@example.com>', to: 'user@x.com', subject: 'Welcome', html: '<p>x</p>',
});
transporter.close();
} finally {
srv.close();
}
});
test('buildSmtpMessage output is something the installed nodemailer can send', async () => {
const { srv, port, seen } = await startFakeSmtp();
try {
const transporter = nodemailer.createTransport({
host: '127.0.0.1', port, secure: false, tls: { rejectUnauthorized: false },
});
// Built by our own code rather than hand-written here, so the two cannot drift apart.
await transporter.sendMail(buildSmtpMessage('to@x.com', 'Subj', 'plain', '<p>rich</p>', 'Sender Name'));
assert.ok(seen.commands.some(c => /^RCPT TO:<to@x\.com>/i.test(c)), 'recipient reached the wire');
assert.match(seen.data, /Subject: Subj/, 'subject reached the wire');
transporter.close();
} finally {
srv.close();
}
});