fix(slideshow): deny display-only token on download/upload/feedback (PR #646 review)

The slideshow JWT reuses type:'gallery', so verifyGalleryAccess accepts it on
every gallery route — a leaked projector link could download (single/all/
selected), upload (when allow_user_uploads), or post feedback for up to ~12h,
beyond its display-only contract. Add a `denySlideshowToken` middleware (403
when req.accessLevel==='slideshow') after verifyGalleryAccess on those 5 routes.
The photo-display routes (/photos, photo/thumbnail/preview/hero) stay open — the
kiosk needs them. +4 tests mint a real slideshow JWT and assert 403. Docs note
that Regenerate/Disable isn't instant revocation (~12h) and the feature flag is
the hard cut-off.
This commit is contained in:
Luca
2026-06-21 02:46:29 +02:00
parent 9dd353744e
commit e36b3309ca
5 changed files with 76 additions and 9 deletions
+18
View File
@@ -171,7 +171,25 @@ async function verifyGalleryAccess(req, res, next) {
}
}
/**
* Deny a slideshow-scoped JWT. The Live Slideshow token (accessLevel
* 'slideshow') is reused as a `type:'gallery'` token so it can read photos for
* the kiosk, which means every verifyGalleryAccess-protected route would
* otherwise accept it. A projector URL is meant to be display-only and is
* comparatively easy to leak (browser history, venue laptop, USB), so this
* gate is placed AFTER verifyGalleryAccess on the write/bulk-download routes to
* keep a leaked slideshow link from downloading, uploading, or posting
* feedback. (#646 review)
*/
function denySlideshowToken(req, res, next) {
if (req.accessLevel === 'slideshow') {
return res.status(403).json({ error: 'Slideshow tokens are display-only' });
}
next();
}
module.exports = {
verifyGalleryAccess,
denySlideshowToken,
isAdminPreview
};
+5 -5
View File
@@ -7,7 +7,7 @@ const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
const { verifyGalleryAccess, denySlideshowToken, isAdminPreview } = require('../middleware/gallery');
const { resolveGuest } = require('../middleware/guestAuth');
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
@@ -807,7 +807,7 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
});
// Download single photo
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
const { photoId } = req.params;
@@ -927,7 +927,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
});
// Download all photos as ZIP
router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
@@ -1103,7 +1103,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
});
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => {
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
@@ -1804,7 +1804,7 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
});
// User photo upload endpoint
router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
+2 -1
View File
@@ -1,7 +1,7 @@
const express = require('express');
const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
@@ -140,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
// Submit feedback for a photo
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
denySlideshowToken,
resolveGuest,
validatePhotoId,
validateFeedbackSubmission,