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,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