fix(events): apply the gallery password policy to publish and send-later (#1253)

Both routes re-hash password_hash from a plaintext the admin re-types, and
both validated it with nothing but express-validator's isLength({min:6}).
So the configured complexity — moderate by default, meaning 8 chars plus
upper, lower and a digit — governed creation and reset while these two doors
accepted 'aaaaaa' and made it the live gallery password.

Fixed for both at once, deliberately. Fixing only the newer send-later route
would have made a quiet-publish password valid at publish time and rejected
by send-later, leaving the admin unable to mail a gallery that is already
live under exactly that password.

Not an escalation — it needs admin auth plus events.edit, and such an admin
could already set the same weak password through /publish. It is a policy
gap: the UI promised a complexity level these two endpoints did not enforce.

BEHAVIOUR CHANGE: an API-only consumer publishing with a sub-policy password
now gets 400 with the same body shape event creation returns (error, details,
score, feedback) instead of silently weakening the gallery. Two existing test
fixtures had to change for the same reason — their intent was that the
supplied password is carried and persisted, not that a weak one is accepted.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-01 08:30:17 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent a35d2bad66
commit 6938bad107
2 changed files with 87 additions and 5 deletions
@@ -225,11 +225,11 @@ describe('publish quietly (#1235)', () => {
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'sup3r-secret' });
.send({ password: 'Sup3r-Secret' });
expect(res.status).toBe(200);
const [queued] = await queuedFor(id);
expect(JSON.parse(queued.email_data).gallery_password).toBe('sup3r-secret');
expect(JSON.parse(queued.email_data).gallery_password).toBe('Sup3r-Secret');
});
it('persists a changed password so the emailed one actually works', async () => {
@@ -241,16 +241,16 @@ describe('publish quietly (#1235)', () => {
const res = await request(app)
.post(`/admin/events/${id}/send-gallery-email`)
.send({ password: 'brand-new-pass' });
.send({ password: 'Brand-New-Pass1' });
expect(res.status).toBe(200);
const bcrypt = require('bcrypt');
const row = await db('events').where({ id }).first();
expect(row.password_hash).not.toBe('stale-hash');
expect(await bcrypt.compare('brand-new-pass', row.password_hash)).toBe(true);
expect(await bcrypt.compare('Brand-New-Pass1', row.password_hash)).toBe(true);
const [queued] = await queuedFor(id);
expect(JSON.parse(queued.email_data).gallery_password).toBe('brand-new-pass');
expect(JSON.parse(queued.email_data).gallery_password).toBe('Brand-New-Pass1');
});
it('does NOT touch the gallery password when only an account notice goes out', async () => {
@@ -336,6 +336,54 @@ describe('publish quietly (#1235)', () => {
expect(res.body.error).toMatch(/no customer email/i);
});
it('applies the configured gallery policy before rehashing, on both doors', async () => {
// Both endpoints re-hash a plaintext the admin re-types, and both used to
// validate it with nothing but isLength({min:6}) — so the configured
// complexity governed creation and reset while these two accepted
// 'aaaaaa' and made it the live gallery password.
const draftId = await seedDraft({ slug: 'weak-publish' });
await db('events').where({ id: draftId }).update({ require_password: 1 });
const publishRes = await request(app)
.post(`/admin/events/${draftId}/publish`)
.send({ password: 'aaaaaa' });
expect(publishRes.status).toBe(400);
expect(publishRes.body.error).toMatch(/security requirements/i);
// And the same password must not sneak in through send-later, or a
// gallery published quietly could still be weakened afterwards.
const [row] = await db('events').insert({
slug: 'weak-send',
event_type: 'wedding',
event_name: 'Weak Send',
event_date: '2026-09-01',
host_email: '',
admin_email: '[email protected]',
customer_email: '[email protected]',
password_hash: 'original-hash',
require_password: 1,
share_link: '/gallery/weak-send/share',
share_token: 'weak-send-token',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const sendId = typeof row === 'object' ? row.id : row;
const sendRes = await request(app)
.post(`/admin/events/${sendId}/send-gallery-email`)
.send({ password: 'aaaaaa' });
expect(sendRes.status).toBe(400);
expect(sendRes.body.error).toMatch(/security requirements/i);
// Rejected means untouched — not rejected after the write.
const after = await db('events').where({ id: sendId }).first();
expect(after.password_hash).toBe('original-hash');
});
it('re-sending is allowed — a lost email should not need an unpublish/republish', async () => {
const id = await seedDraft({ slug: 'resend' });
await request(app).post(`/admin/events/${id}/publish`).send({});