fix(email): scrub gallery passwords from the sent-mail archive (#1340)
* fix(email): scrub gallery passwords from the sent-mail archive
The email queue kept every gallery password and client PIN in clear
text in email_data and rendered_html after the mail was sent, and the
Messages reading pane handed them back to any admin with the messaging
flag. A password hash in the events table bought nothing while the
plaintext sat next to it.
Once a mail is out, or its retries are exhausted, the processor now
masks secret-looking variables (password, passcode, pin) in email_data
and replaces their values in the rendered body, plain and HTML-escaped.
The reading pane applies the same masking to rows archived before this
change. Pending rows keep the real values so a retry still sends them.
Relates to issue 1271
* fix(email): keep a quoted ">" from cutting an attribute value out of redaction
The tag splitter stopped at the first ">", so a template attribute such as
title="{{gallery_password}} > details" left the password unmasked in the
archived HTML while email_data was already masked. The tokenizer is now
quote-aware; a tag with an unbalanced quote falls through as text and is
scrubbed there.
* fix(email): scrub secrets inside HTML comments in the archived body
A comment such as <!-- PIN: {{client_password}} --> was split off as a tag
and its body, which has no attribute, was never scrubbed. Comments are now
one segment and their content is masked whole.
---------
Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
8017370271
commit
69754f8a2c
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The Messages reading pane must not serve passwords (see
|
||||
* utils/emailSecretRedaction.js). Rows sent before the processor learned to
|
||||
* scrub still carry the gallery password in email_data and rendered_html;
|
||||
* the route redacts them on read.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paneredact-')), 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'paneredact-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-paneredact-storage-'));
|
||||
|
||||
const request = require('supertest');
|
||||
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp } = require('../integration/helpers/crmDb');
|
||||
const { invalidateFeatureFlagCache } = require('../../src/middleware/requireFeatureFlag');
|
||||
const { MASK } = require('../../src/utils/emailSecretRedaction');
|
||||
|
||||
describe('GET /admin/email/queue/:id redacts secrets from legacy rows', () => {
|
||||
let db; let cleanup; let app; let token; let rowId;
|
||||
const PASSWORD = 'Sunset-42!'; const PIN = 'Tom & Ada\'s 7788';
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
await db('feature_flags').insert({ key: 'messaging', value: true }).onConflict('key').merge({ value: true });
|
||||
invalidateFeatureFlagCache();
|
||||
const ins = await db('email_queue').insert({
|
||||
recipient_email: '[email protected]', email_type: 'gallery_created', status: 'sent',
|
||||
created_at: new Date().toISOString(), sent_at: new Date().toISOString(), retry_count: 0,
|
||||
email_data: JSON.stringify({ customer_name: 'Ada', gallery_password: PASSWORD, client_password: PIN, cc: ['[email protected]'] }),
|
||||
rendered_html: `<ul><li>Password: ${PASSWORD}</li><li>PIN: Tom & Ada's 7788</li></ul>`,
|
||||
}).returning('id');
|
||||
rowId = ins[0]?.id ?? ins[0];
|
||||
app = buildRouteApp('/api/admin/email', require('../../src/routes/adminEmail'));
|
||||
}, 120000);
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('masks the password and the PIN in the rendered body, keeps the rest', async () => {
|
||||
const res = await request(app).get(`/api/admin/email/queue/${rowId}`).set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.renderedHtml).not.toContain(PASSWORD);
|
||||
expect(res.body.renderedHtml).not.toContain('Ada's 7788');
|
||||
expect(res.body.renderedHtml).toContain(`Password: ${MASK}`);
|
||||
expect(res.body.renderedHtml).toContain(`PIN: ${MASK}`);
|
||||
expect(res.body.cc).toBe('[email protected]');
|
||||
expect(JSON.stringify(res.body)).not.toContain(PASSWORD);
|
||||
// the stored row is untouched by a read
|
||||
const row = await db('email_queue').where('id', rowId).first();
|
||||
expect(row.rendered_html).toContain(PASSWORD);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Passwords leave the email archive once a row is final (see
|
||||
* utils/emailSecretRedaction.js). The gallery-created email carries the
|
||||
* gallery password and the client PIN; after the mail is out — or after the
|
||||
* row is out of retries — neither survives in email_data or rendered_html.
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.TEST_DATABASE_PATH = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mailredact-')), 'db.sqlite');
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mailredact-test-secret';
|
||||
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mailredact-storage-'));
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
const { MASK } = require('../../src/utils/emailSecretRedaction');
|
||||
|
||||
function stubWebhookTransport(impl) {
|
||||
const transport = require('../../src/services/emailWebhookTransport');
|
||||
const savedFrom = process.env.EMAIL_FROM;
|
||||
process.env.EMAIL_FROM = '[email protected]';
|
||||
const mails = [];
|
||||
const enabled = jest.spyOn(transport, 'isEnabled').mockReturnValue(true);
|
||||
// mockRestore() wipes mock.calls, so keep our own copy of what went out
|
||||
const send = jest.spyOn(transport, 'send').mockImplementation(async (mail) => { mails.push(mail); return impl(mail); });
|
||||
return { mails, restore() { enabled.mockRestore(); send.mockRestore(); if (savedFrom === undefined) delete process.env.EMAIL_FROM; else process.env.EMAIL_FROM = savedFrom; } };
|
||||
}
|
||||
|
||||
describe('email archive redaction', () => {
|
||||
let db; let cleanup; let eventId;
|
||||
const PASSWORD = 'Sunset-42!'; const PIN = '7788';
|
||||
const queue = (extra = {}) => db('email_queue').insert({
|
||||
event_id: eventId, recipient_email: '[email protected]', email_type: 'gallery_created',
|
||||
email_data: JSON.stringify({
|
||||
customer_name: 'Ada', host_name: 'Ada', event_name: 'Redaction Wedding', event_date: '2026-09-07',
|
||||
gallery_link: 'https://photos.example/gallery/redaction-wedding/tok', gallery_password: PASSWORD,
|
||||
client_link: 'https://photos.example/gallery/redaction-wedding/client-access?token=abc', client_password: PIN,
|
||||
expiry_date: null, welcome_message: '',
|
||||
}),
|
||||
status: 'pending', created_at: new Date().toISOString(), scheduled_at: new Date().toISOString(), retry_count: 0,
|
||||
...extra,
|
||||
}).returning('id').then((r) => r[0]?.id ?? r[0]);
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
await seedMinimal(db);
|
||||
const ins = await db('events').insert({
|
||||
slug: 'redaction-wedding', event_type: 'wedding', event_name: 'Redaction Wedding', event_date: '2026-09-07',
|
||||
customer_email: '[email protected]', customer_name: 'Ada', password_hash: 'x', share_link: '/gallery/redaction-wedding/tok',
|
||||
share_token: 'tok', expires_at: new Date(Date.now() + 86400000).toISOString(), is_active: true, created_at: new Date().toISOString(),
|
||||
}).returning('id');
|
||||
eventId = ins[0]?.id ?? ins[0];
|
||||
}, 120000);
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
it('scrubs the variables and the rendered body once the mail is out', async () => {
|
||||
const id = await queue();
|
||||
const stub = stubWebhookTransport(async () => ({ messageId: 'sent-1' }));
|
||||
try {
|
||||
const { processEmailQueue } = require('../../src/services/emailProcessor');
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
|
||||
} finally { stub.restore(); }
|
||||
const row = await db('email_queue').where('id', id).first();
|
||||
expect(row.status).toBe('sent');
|
||||
const data = JSON.parse(row.email_data);
|
||||
expect(data.gallery_password).toBe(MASK);
|
||||
expect(data.client_password).toBe(MASK);
|
||||
expect(data.customer_name).toBe('Ada');
|
||||
expect(row.rendered_html).toBeTruthy();
|
||||
expect(row.rendered_html).not.toContain(PASSWORD);
|
||||
expect(row.rendered_html).not.toContain(PIN);
|
||||
expect(row.rendered_html).toContain(MASK);
|
||||
// the mail itself went out with the real password; only the archive lost it
|
||||
expect(stub.mails).toHaveLength(1);
|
||||
expect(String(stub.mails[0].html)).toContain(PASSWORD);
|
||||
expect(String(stub.mails[0].html)).not.toContain(MASK);
|
||||
|
||||
// Messages "resend" copies the archived variables into a new pending
|
||||
// row and "retry" re-queues the row itself: both mails must say the
|
||||
// password is not shown rather than print the mask (or the password).
|
||||
const { resendEmail, retryEmail } = require('../../src/services/projectService');
|
||||
const resent = await resendEmail(id);
|
||||
await retryEmail(id);
|
||||
for (const rowId of [resent.id, id]) {
|
||||
const stub2 = stubWebhookTransport(async () => ({ messageId: `sent-again-${rowId}` }));
|
||||
try {
|
||||
const { processEmailQueue } = require('../../src/services/emailProcessor');
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: rowId });
|
||||
} finally { stub2.restore(); }
|
||||
expect(stub2.mails).toHaveLength(1);
|
||||
const html = String(stub2.mails[0].html);
|
||||
expect(html).not.toContain(MASK);
|
||||
expect(html).not.toContain(PASSWORD);
|
||||
expect(html).not.toContain(PIN);
|
||||
expect(html).not.toContain('{{password_security_message}}');
|
||||
expect(html).toContain('security');
|
||||
// archived again without the password (mask or sentinel, never the value)
|
||||
expect(JSON.parse((await db('email_queue').where('id', rowId).first()).email_data).gallery_password).not.toBe(PASSWORD);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the variables in the clear while the row can still be retried', async () => {
|
||||
const id = await queue({ retry_count: 1 });
|
||||
const stub = stubWebhookTransport(async () => { throw new Error('transport down'); });
|
||||
try {
|
||||
const { processEmailQueue } = require('../../src/services/emailProcessor');
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
|
||||
let row = await db('email_queue').where('id', id).first();
|
||||
expect(row.retry_count).toBe(2);
|
||||
expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD);
|
||||
// out of retries — a Messages "retry" resets the counter and this row
|
||||
// must still be able to send the real password
|
||||
await processEmailQueue({ ignoreSchedule: true, onlyId: id });
|
||||
row = await db('email_queue').where('id', id).first();
|
||||
expect(row.retry_count).toBe(3);
|
||||
expect(row.status).not.toBe('sent');
|
||||
expect(JSON.parse(row.email_data).gallery_password).toBe(PASSWORD);
|
||||
} finally { stub.restore(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Passwords must not survive in the email archive (see the module header of
|
||||
* utils/emailSecretRedaction.js).
|
||||
*/
|
||||
const { secretValues, redactEmailData, redactRenderedHtml, replaceMaskedSecrets, parseEmailData, MASK } = require('../../src/utils/emailSecretRedaction');
|
||||
|
||||
describe('email secret redaction', () => {
|
||||
const data = {
|
||||
customer_name: 'Ada', gallery_link: 'https://p.example/gallery/x/tok',
|
||||
gallery_password: 'Sunset-42!', client_password: '1234', welcome_message: 'hi',
|
||||
attachments: [{ filename: 'a.pdf', password: 'zip-secret' }],
|
||||
};
|
||||
|
||||
it('finds the secrets by key name, nested included, and skips sentinels', () => {
|
||||
expect(secretValues(data).sort()).toEqual(['1234', 'Sunset-42!', 'zip-secret']);
|
||||
expect(secretValues({ gallery_password: '{{password_security_message}}' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: 'No password required' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: '(set at creation)' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: '' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('masks the secrets in the variables and leaves everything else alone', () => {
|
||||
const redacted = redactEmailData(data);
|
||||
expect(redacted.gallery_password).toBe(MASK);
|
||||
expect(redacted.client_password).toBe(MASK);
|
||||
expect(redacted.attachments[0].password).toBe(MASK);
|
||||
expect(redacted.attachments[0].filename).toBe('a.pdf');
|
||||
expect(redacted.customer_name).toBe('Ada');
|
||||
expect(redacted.gallery_link).toBe(data.gallery_link);
|
||||
// sentinels stay readable
|
||||
expect(redactEmailData({ gallery_password: 'No password required' }).gallery_password).toBe('No password required');
|
||||
// the input is not mutated
|
||||
expect(data.gallery_password).toBe('Sunset-42!');
|
||||
});
|
||||
|
||||
it('strips the secrets from the rendered HTML, plain and HTML-escaped', () => {
|
||||
const html = '<li>Password: Sunset-42!</li><li>PIN: 1234</li><p>Tom & Ada's day</p>';
|
||||
const out = redactRenderedHtml(html, secretValues({ ...data, client_password: 'Tom & Ada\'s day' }));
|
||||
expect(out).toContain(`Password: ${MASK}`);
|
||||
expect(out).not.toContain('Sunset-42!');
|
||||
expect(out).toContain(`<p>${MASK}</p>`);
|
||||
expect(redactRenderedHtml(html, [])).toBe(html);
|
||||
expect(redactRenderedHtml(null, ['x'])).toBeNull();
|
||||
});
|
||||
|
||||
it('parses stored email_data leniently', () => {
|
||||
expect(parseEmailData('{"a":1}')).toEqual({ a: 1 });
|
||||
expect(parseEmailData({ a: 1 })).toEqual({ a: 1 });
|
||||
expect(parseEmailData('not json')).toEqual({});
|
||||
expect(parseEmailData(null)).toEqual({});
|
||||
});
|
||||
|
||||
it('ignores the pipeline sentinels but not a brace-wrapped real password', () => {
|
||||
expect(secretValues({ gallery_password: '(set at creation)', client_password: 'No password required' })).toEqual([]);
|
||||
expect(secretValues({ gallery_password: '{{Sunset-42!}}' })).toEqual(['{{Sunset-42!}}']);
|
||||
});
|
||||
|
||||
it('replaceMaskedSecrets swaps archive masks for the security sentinel, leaves the rest', () => {
|
||||
const out = replaceMaskedSecrets({ customer_name: 'Ada', gallery_password: MASK, client_password: MASK, nested: { pin: MASK, note: MASK } });
|
||||
expect(out).toEqual({
|
||||
customer_name: 'Ada',
|
||||
gallery_password: '{{password_security_message}}',
|
||||
client_password: '{{password_security_message}}',
|
||||
nested: { pin: '{{password_security_message}}', note: MASK },
|
||||
});
|
||||
});
|
||||
|
||||
it('masks a raw secret that the template turned into markup', () => {
|
||||
const html = '<p>PIN: Se<cr3t>Pin42! and Se<cr3t>Pin42!</p>';
|
||||
expect(redactRenderedHtml(html, ['Se<cr3t>Pin42!'])).toBe(`<p>PIN: ${MASK} and ${MASK}</p>`);
|
||||
});
|
||||
|
||||
it('masks a secret in a quoted attribute value that contains ">"', () => {
|
||||
const html = '<a title="Sunset42 > details" href="/x">Sunset42</a>';
|
||||
expect(redactRenderedHtml(html, ['Sunset42'])).toBe(`<a title="${MASK} > details" href="/x">${MASK}</a>`);
|
||||
// unbalanced quote: the broken tag is treated as text, still scrubbed
|
||||
expect(redactRenderedHtml('<a title="Sunset42>Sunset42</a>', ['Sunset42'])).toBe(`<a title="${MASK}>${MASK}</a>`);
|
||||
});
|
||||
|
||||
it('masks a secret inside an HTML comment, ">" included', () => {
|
||||
const html = '<!-- PIN: 7788 --><p>x</p><!-- a > 7788 -->';
|
||||
expect(redactRenderedHtml(html, ['7788'])).toBe(`<!-- PIN: ${MASK} --><p>x</p><!-- a > ${MASK} -->`);
|
||||
});
|
||||
|
||||
it('masks overlapping secrets completely and leaves markup alone', () => {
|
||||
const html = '<p>Password: Sunset-42! PIN: Sunset-42!7788</p><a href="https://x.example/?p=Sunset-42!" style="color:red" title=7788>href</a>';
|
||||
const out = redactRenderedHtml(html, ['Sunset-42!', 'Sunset-42!7788', 'href', 'style', '7788']);
|
||||
expect(out).toBe(`<p>Password: ${MASK} PIN: ${MASK}</p><a href="https://x.example/?p=${MASK}" style="color:red" title=${MASK}>${MASK}</a>`);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const emailWebhookTransport = require('../services/emailWebhookTransport');
|
||||
const businessProfileService = require('../services/businessProfileService');
|
||||
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
|
||||
const logger = require('../utils/logger');
|
||||
const { parseEmailData, secretValues, redactRenderedHtml } = require('../utils/emailSecretRedaction');
|
||||
const router = express.Router();
|
||||
|
||||
// Get email configuration
|
||||
@@ -792,8 +793,12 @@ router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view
|
||||
|
||||
let cc = null;
|
||||
let attachments = [];
|
||||
// Rows sent before the processor learned to scrub still carry the
|
||||
// gallery password / client PIN in their variables and body. Redact on
|
||||
// read from the same rule, so the pane never serves a password.
|
||||
const data = parseEmailData(row.email_data);
|
||||
const renderedHtml = redactRenderedHtml(row.rendered_html || null, secretValues(data));
|
||||
try {
|
||||
const data = row.email_data ? JSON.parse(row.email_data) : {};
|
||||
if (data.cc) cc = Array.isArray(data.cc) ? data.cc.join(', ') : String(data.cc);
|
||||
if (Array.isArray(data.attachments)) {
|
||||
attachments = data.attachments
|
||||
@@ -815,7 +820,7 @@ router.get('/queue/:id', adminAuth, messagingGate, requirePermission('email.view
|
||||
eventId: row.event_id,
|
||||
eventName: row.event_name || null,
|
||||
eventSlug: row.event_slug || null,
|
||||
renderedHtml: row.rendered_html || null,
|
||||
renderedHtml,
|
||||
cc,
|
||||
attachments,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { secretValues, redactEmailData, redactRenderedHtml, replaceMaskedSecrets, isSecretKey } = require('../utils/emailSecretRedaction');
|
||||
const nodemailer = require('nodemailer');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -755,8 +756,13 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
es: 'La contraseña que estableciste al crear la galería',
|
||||
};
|
||||
|
||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
// Every secret variable, not only gallery_password: a resent copy of an
|
||||
// archived mail carries the sentinel in client_password or new_password
|
||||
// too (see emailSecretRedaction.replaceMaskedSecrets).
|
||||
for (const [key, value] of Object.entries(processedVariables)) {
|
||||
if (value === '{{password_security_message}}' && isSecretKey(key)) {
|
||||
processedVariables[key] = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
}
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
@@ -1219,10 +1225,17 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
|
||||
result.processed = pendingEmails.length;
|
||||
|
||||
for (const email of pendingEmails) {
|
||||
// Declared outside the try: the failure branch redacts the variables
|
||||
// once the row is out of retries, so it needs them too.
|
||||
let emailData = {};
|
||||
try {
|
||||
const emailData = typeof email.email_data === 'string'
|
||||
emailData = typeof email.email_data === 'string'
|
||||
? JSON.parse(email.email_data || '{}')
|
||||
: email.email_data || {};
|
||||
// A re-queued row (Messages resend / retry / send now) may carry the
|
||||
// archive mask where its passwords used to be; the sentinel makes
|
||||
// the template say "not shown" instead of mailing the mask.
|
||||
emailData = replaceMaskedSecrets(emailData);
|
||||
|
||||
// Language is resolved from emailData.eventId (event.language is the top
|
||||
// priority). queueEmail injects it, but direct email_queue inserts (e.g.
|
||||
@@ -1270,9 +1283,15 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
|
||||
// Overview email preview (guarded — older installs without migration
|
||||
// 119 just skip it).
|
||||
const sentUpdate = { status: 'sent', sent_at: new Date().toISOString() };
|
||||
// The mail is out: this is the last moment the variables were needed
|
||||
// in the clear. Gallery passwords and client PINs are bcrypt-hashed
|
||||
// everywhere else; without this the archive kept them readable for
|
||||
// the life of the event, and the Messages pane served them back.
|
||||
const secrets = secretValues(emailData);
|
||||
sentUpdate.email_data = JSON.stringify(redactEmailData(emailData));
|
||||
try {
|
||||
if (sendResult && sendResult.html && await hasColumnCached('email_queue', 'rendered_html')) {
|
||||
sentUpdate.rendered_html = sendResult.html;
|
||||
sentUpdate.rendered_html = redactRenderedHtml(sendResult.html, secrets);
|
||||
}
|
||||
} catch (_) { /* best-effort — never block the send on the preview */ }
|
||||
await db('email_queue')
|
||||
@@ -1295,7 +1314,10 @@ async function processEmailQueue({ ignoreSchedule = false, limit = 10, onlyId =
|
||||
logger.info(`Email ${email.id} sent successfully`);
|
||||
} catch (error) {
|
||||
result.failed += 1;
|
||||
// Increment retry count
|
||||
// Increment retry count. The variables stay in the clear on
|
||||
// failure: a row past the cap can still be re-queued (Messages
|
||||
// "retry" resets retry_count, ignoreSchedule skips the cap) and a
|
||||
// masked password would then be mailed out as the real one.
|
||||
try {
|
||||
await db('email_queue')
|
||||
.where('id', email.id)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Keep passwords out of the email archive.
|
||||
*
|
||||
* email_queue rows carry the template variables (`email_data`) and, since
|
||||
* migration 119, the exact HTML that went out (`rendered_html`). The
|
||||
* gallery-created email includes the gallery password and the client PIN,
|
||||
* so both columns held those in clear text for the life of the event, and
|
||||
* the Messages reading pane returned them to any admin with email.view.
|
||||
* Gallery passwords are bcrypt-hashed everywhere else; this was the one
|
||||
* place they survived in plain text.
|
||||
*
|
||||
* The variables have to stay intact until the mail is out — the processor
|
||||
* renders from them, and a retry needs them again — so the scrub runs when
|
||||
* a row reaches a final state (sent, or out of retries). Rows written before
|
||||
* that are redacted on read from the same rule.
|
||||
*/
|
||||
|
||||
const SECRET_KEY_RE = /password|passcode|\bpin\b|_pin$/i;
|
||||
const MASK = '••••••';
|
||||
// Template sentinels the email pipeline uses in place of a real password.
|
||||
// They are not secrets and masking them would hide what the email said.
|
||||
const SENTINELS = new Set(['{{password_security_message}}', 'No password required', '(set at creation)', MASK]);
|
||||
|
||||
function isSecretKey(key) {
|
||||
return SECRET_KEY_RE.test(String(key));
|
||||
}
|
||||
|
||||
function isRealSecret(value) {
|
||||
return typeof value === 'string' && value.length > 0 && !SENTINELS.has(value);
|
||||
}
|
||||
|
||||
/** The secret strings inside a template-variable object, deduplicated. */
|
||||
function secretValues(emailData) {
|
||||
const out = new Set();
|
||||
const walk = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') return;
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === 'object') walk(value);
|
||||
else if (isSecretKey(key) && isRealSecret(value)) out.add(value);
|
||||
}
|
||||
};
|
||||
walk(emailData);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** A copy of the template variables with every secret replaced by the mask. */
|
||||
function redactEmailData(emailData) {
|
||||
if (!emailData || typeof emailData !== 'object') return emailData;
|
||||
const copy = Array.isArray(emailData) ? [] : {};
|
||||
for (const [key, value] of Object.entries(emailData)) {
|
||||
if (value && typeof value === 'object') copy[key] = redactEmailData(value);
|
||||
else copy[key] = isSecretKey(key) && isRealSecret(value) ? MASK : value;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every occurrence of the given secrets in a rendered body — as
|
||||
* typed, and as the template engine would have HTML-escaped it.
|
||||
*/
|
||||
function redactRenderedHtml(html, secrets) {
|
||||
if (!html || !secrets || !secrets.length) return html;
|
||||
// One pass per group, longest form first: when one secret contains
|
||||
// another ('Sunset-42!' inside 'Sunset-42!7788'), replacing the short one
|
||||
// first would leave the tail of the long one readable.
|
||||
const forms = [...new Set(secrets.flatMap((secret) => [secret, escapeHtml(secret)]))]
|
||||
.filter((form) => form.length > 0)
|
||||
.sort((a, b) => b.length - a.length);
|
||||
const alternation = (list) => new RegExp(list.map(escapeRegExp).join('|'), 'g');
|
||||
// A word-like secret ('href', 'style', '7788') could also be a tag or
|
||||
// attribute name, so it is only replaced in text and quoted attribute
|
||||
// values. Anything else cannot be markup and is replaced wherever it
|
||||
// occurs — including a raw 'Se<cr3t>Pin' that the splitter would cut.
|
||||
const wordLike = forms.filter((form) => /^[\w-]+$/.test(form));
|
||||
const other = forms.filter((form) => !/^[\w-]+$/.test(form));
|
||||
let out = String(html);
|
||||
if (other.length) out = out.replace(alternation(other), MASK);
|
||||
if (!wordLike.length) return out;
|
||||
const scrub = (text) => text.replace(alternation(wordLike), MASK);
|
||||
// A '>' inside a quoted attribute value (title="{{gallery_password}} > more")
|
||||
// must not end the tag, or the value is cut off and never scrubbed. A tag
|
||||
// with an unbalanced quote does not match and is scrubbed as text instead.
|
||||
// A comment (<!-- PIN: {{client_password}} -->) is one segment and its body
|
||||
// is scrubbed whole: it holds no tag or attribute names.
|
||||
return out.split(/(<!--[\s\S]*?-->|<(?:[^>"']|"[^"]*"|'[^']*')*>)/).map((segment, index) => {
|
||||
if (index % 2 === 0) return scrub(segment);
|
||||
if (segment.startsWith('<!--')) return `<!--${scrub(segment.slice(4, -3))}-->`;
|
||||
// attribute values, quoted or not; never the tag or attribute names
|
||||
return segment.replace(/(=\s*)("[^"]*"|'[^']*'|[^\s"'>]+)/g, (_, eq, value) => eq + scrub(value));
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** Parse a stored email_data column leniently (string or already-parsed). */
|
||||
function parseEmailData(raw) {
|
||||
if (!raw) return {};
|
||||
if (typeof raw !== 'string') return raw;
|
||||
try { return JSON.parse(raw); } catch (_) { return {}; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo the archive mask for a row that is about to be SENT again (Messages
|
||||
* "resend" copies a sent row's variables into a new pending row, "retry"
|
||||
* and "send now" re-queue the row itself). The real value is gone; the
|
||||
* pipeline's security sentinel makes the template say so instead of
|
||||
* mailing six dots as the password. Applied by processEmailQueue, so every
|
||||
* requeue path is covered.
|
||||
*/
|
||||
function replaceMaskedSecrets(emailData, sentinel = '{{password_security_message}}') {
|
||||
const walk = (obj) => {
|
||||
if (!obj || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(walk);
|
||||
const out = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value && typeof value === 'object') out[key] = walk(value);
|
||||
else if (isSecretKey(key) && value === MASK) out[key] = sentinel;
|
||||
else out[key] = value;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(emailData);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceMaskedSecrets, MASK, isSecretKey, secretValues, redactEmailData, redactRenderedHtml, parseEmailData };
|
||||
Reference in New Issue
Block a user