Files
picpeak/backend/__tests__/routes/adminEmail.queueRedaction.test.js
T
Paul Nothaft 69754f8a2c 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 <paul@MacStudio-von-Paul.local>
2026-09-07 20:02:57 +02:00

57 lines
2.9 KiB
JavaScript

/**
* 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: 'client@example.com', 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: ['second@example.com'] }),
rendered_html: `<ul><li>Password: ${PASSWORD}</li><li>PIN: Tom &amp; Ada&#39;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&#39;s 7788');
expect(res.body.renderedHtml).toContain(`Password: ${MASK}`);
expect(res.body.renderedHtml).toContain(`PIN: ${MASK}`);
expect(res.body.cc).toBe('second@example.com');
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);
});
});