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:
Paul Nothaft
2026-09-07 20:02:57 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent 8017370271
commit 69754f8a2c
6 changed files with 433 additions and 7 deletions
+7 -2
View File
@@ -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,
});
+27 -5
View File
@@ -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)
+132
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* 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 };