Files
picpeak/backend/src/middleware/guestAuth.js
T
Paul Nothaft 081f3edcdf fix(security): close cross-event thumbnail leak, bulk-op ownership bypass, + hardening
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.

HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
  flat /thumbnails/thumb_* file, so a visitor to one gallery could
  enumerate another (password-protected) gallery's entire thumbnail set.
  Scope thumbnail access to the token's event via photos.thumbnail_path.
  Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
  event ids with no owner filter (single-event routes enforce
  requireEventOwnership), letting admin/editor archive or cascade-delete
  any event. Add filterOwnedEventIds; also guard rename + import-external;
  tighten photo-retry to scope admin (not just editor). Fix misleading
  bulk-delete comment.

MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
  instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
  (#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
  to absolute/external URLs — only attach to relative same-app paths.

LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.

Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
2026-07-03 10:27:28 +02:00

107 lines
2.7 KiB
JavaScript

const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
/**
* Non-blocking middleware. Reads an optional guest token from the request and,
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
*
* If the token is missing, malformed, or expired → req.guest = null and the
* request continues. Downstream handlers (e.g. feedback submission) enforce
* presence explicitly based on event feedback settings (identity_mode).
*/
async function resolveGuest(req, res, next) {
try {
const slug = req.params?.slug;
const token = getGuestTokenFromRequest(req, slug);
if (!token) {
req.guest = null;
return next();
}
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth',
complete: true,
});
decoded = verified.payload;
} catch (err) {
// Invalid or expired guest tokens are silently ignored so that public
// gallery browsing continues to work even if the token is stale.
logger.debug('Invalid guest token', { reason: err.message });
req.guest = null;
return next();
}
if (decoded.type !== 'guest') {
req.guest = null;
return next();
}
// Verify the guest row still exists and is not soft-deleted.
const guest = await db('gallery_guests')
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
.first();
if (!guest) {
req.guest = null;
return next();
}
req.guest = {
id: guest.id,
eventId: guest.event_id,
identifier: guest.identifier,
name: guest.name,
email: guest.email || null,
};
return next();
} catch (error) {
logger.error('resolveGuest middleware error', { error: error.message });
req.guest = null;
return next();
}
}
/**
* Blocking middleware that 401s if no guest identity was resolved.
* Use this on endpoints that require a valid guest session.
*/
function requireGuest(req, res, next) {
if (!req.guest) {
return res.status(401).json({ error: 'Guest identity required' });
}
return next();
}
/**
* Sign a new guest JWT. Scoped to a specific event and guest row.
* Expiry matches the gallery token default (24h).
*/
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
return jwt.sign(
{
type: 'guest',
guestId,
eventId,
identifier,
name,
},
process.env.JWT_SECRET,
{
issuer: 'picpeak-auth',
expiresIn,
}
);
}
module.exports = {
resolveGuest,
requireGuest,
signGuestToken,
};