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
+34
View File
@@ -31,6 +31,34 @@ const downloadZipService = require('../../services/downloadZipService');
const { resolveEventFeedbackDefaults, applyFeedbackDefaults, KEYBIND_MODES } = require('../../services/feedbackDefaults');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
/**
* Validate a gallery password the admin re-typed, against the SAME policy
* event creation applies.
*
* Both the publish dialog (#627) and the send-later route (#1235) re-hash
* `password_hash` from a plaintext the admin types again, and both validated
* it with nothing but express-validator's `isLength({ min: 6 })`. So the
* configured complexity — moderate by default — governed creation and reset
* while these two doors accepted `aaaaaa` and made it the live password.
*
* Fixed in one place and for both, deliberately. Fixing only the newer 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 that exact password.
*
* Returns null when the password passes; otherwise the response body to send.
*/
async function checkGalleryPasswordPolicy(password, eventName) {
const result = await validatePasswordInContext(password, 'gallery', { eventName });
if (result.valid) return null;
return {
error: 'Password does not meet security requirements',
details: result.errors,
score: result.score,
feedback: result.feedback,
};
}
/**
* Can this assigned customer account actually receive — and act on — the
* gallery notice? (#1235)
@@ -1036,6 +1064,9 @@ module.exports = (router) => {
// the live gallery password and lock out everyone holding the old one,
// in exchange for nothing.
if (hasInlineRecipient && requirePassword && password) {
const policyError = await checkGalleryPasswordPolicy(password, event.event_name);
if (policyError) return res.status(400).json(policyError);
await db('events').where('id', id).update({
password_hash: await bcrypt.hash(password, getBcryptRounds()),
});
@@ -1132,6 +1163,9 @@ module.exports = (router) => {
// Re-hash so the stored hash matches what the email carries — even if
// the admin mistypes vs. what was set at draft creation, the gallery
// password the customer receives is the one that actually works.
const policyError = await checkGalleryPasswordPolicy(password, event.event_name);
if (policyError) return res.status(400).json(policyError);
publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds());
}
await db('events').where('id', id).update(publishUpdates);