Files
picpeak/backend/src/routes/secureImages.js
T
Paul Nothaft d1c582396a fix: enforce gallery access and consolidate gallery workflows
Apply shared session, permission, ownership and lifecycle checks across
gallery access, media grants and session restoration. Validate mutation
origins, pin webhook DNS resolution and redact token-bearing request URLs.

Consolidate gallery creation and queries, extract frontend state hooks,
fix hook ordering and resource cleanup, and repair the fresh event schema.
Update affected dependencies and restore excluded CI suites with regression
and cross-database coverage.
2026-09-08 11:10:03 +02:00

486 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const express = require('express');
const { resolvePhotoContentType } = require('../utils/photoContentType');
const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const { blockHiddenGallery, bypassesReveal, isGalleryHidden } = require('../utils/revealMode');
const secureImageService = require('../services/secureImageService');
const galleryAccessService = require('../services/galleryAccessService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { parseBooleanInput } = require('../utils/parsers');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const {
getUseOriginalFilenames,
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { isPhotoHiddenFromViewer, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const router = express.Router();
/**
* Generate secure token for image access
*/
router.post('/:slug/generate-token', async (req, res, next) => {
// Add slug to request for verifyGalleryAccess
req.requestedSlug = req.params.slug;
next();
}, verifyGalleryAccess, denySlideshowToken, blockHiddenGallery, async (req, res) => {
try {
const { photoId, accessType = 'view' } = req.body;
if (!photoId) {
return res.status(400).json({ error: 'Photo ID required' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Don't mint a secure-image capability for a hidden/client-only photo
// when the caller isn't a client (the token is reusable up to 3×).
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Create client fingerprint
const clientFingerprint = secureImageService.createClientFingerprint(req);
// Get protection level from event settings
const protectionLevel = req.event.protection_level || 'standard';
// Generate secure token with appropriate settings
const tokenOptions = {
galleryAccess: req.galleryAccess,
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
protectionLevel,
// Reveal mode (#838): recorded in the token so a re-hide invalidates
// in-flight guest tokens at serve time without breaking the slideshow.
revealBypass: bypassesReveal(req),
// TOCTOU: a client's token keeps serving a photo hidden after minting;
// a guest's stops the moment it's hidden (checked at the serve route).
clientBypass: canSeeHiddenPhotos(req.accessLevel)
};
const token = secureImageService.generateSecureToken(
photoId,
req.sessionID || 'anonymous',
tokenOptions
);
// Log token generation
await secureImageService.logImageAccess(
photoId,
req.event.id,
{
ip: req.ip,
userAgent: req.get('User-Agent'),
fingerprint: clientFingerprint
},
'token_generated'
);
res.json({
token,
expiresIn: tokenOptions.expiresIn,
maxUses: tokenOptions.maxUses,
protectionLevel
});
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error generating secure token', {
error: error.message,
photoId: req.body.photoId,
eventId: req.event?.id
});
res.status(500).json({ error: 'Failed to generate secure token' });
}
});
/**
* Serve protected image with security measures
*/
router.get('/:slug/secure/:photoId/:token',
secureImageMiddleware.secureImageAccess,
async (req, res) => {
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
try {
logger.debug('Secure image route hit', {
slug,
photoId,
tokenLength: token?.length,
hasAuthHeader: Boolean(req.headers.authorization),
});
// Verify secure token
const tokenValidation = secureImageService.verifySecureToken(
token,
req.clientInfo.fingerprint
);
if (!tokenValidation.valid) {
// Get event for logging (best effort)
const event = await db('events').where({ slug }).first();
await secureImageService.logImageAccess(
photoId,
event?.id || 0,
req.clientInfo,
'token_invalid'
);
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Get event from slug
const event = await db('events')
.where({
slug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// Revalidate the issuing session, ownership and gallery lifecycle at
// every use, including capabilities minted before logout or restore.
await galleryAccessService.authorize(event, tokenValidation.data?.galleryAccess);
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
// download sibling does. Instead enforce the scope already inside the
// token: it is minted for one photoId (and photos belong to exactly
// one gallery), and its sessionId records the minting gallery's id.
// Without this, a token minted on any PUBLIC gallery reads every other
// gallery's photos with no password.
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'photo_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Defense in depth: the sessionId embeds the gallery the token was
// minted for (`gallery_public_<id>_...` / `gallery_<id>_...`). Reject a
// token whose gallery is parseable and differs from this one.
const sessionEventId = Number(
(String(tokenValidation.data?.sessionId || '').match(/^gallery_(?:public_)?(\d+)_/) || [])[1]
);
if (Number.isInteger(sessionEventId) && sessionEventId !== Number(event.id)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'gallery_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this gallery' });
}
// Reveal mode (#838): tokens minted by plain guests die the moment the
// gallery is (re-)hidden — bypass contexts keep working.
if (isGalleryHidden(event) && !tokenValidation.data?.revealBypass) {
return res.status(403).json({ error: 'Gallery is hidden until reveal', code: 'GALLERY_HIDDEN' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Recheck visibility at serve time (TOCTOU): a photo hidden AFTER the
// token was minted must stop serving, unless the token was minted by a
// client (clientBypass) — mirroring the reveal-mode check above.
if (photo.visibility === 'hidden' && !tokenValidation.data?.clientBypass) {
return res.status(403).json({ error: 'Photo not available' });
}
// Resolve photo through storage backend (managed) or fall back to local
// path (external reference mode). secureImageService needs a local file,
// so we materialize a tmp copy via withLocalCopy in S3 mode.
const storageKey = resolvePhotoStorageKey(event, photo);
// Get protection settings for this event
const protectionSettings = {
protectionLevel: event.protection_level || 'standard',
quality: event.image_quality || 85,
addFingerprint: event.add_fingerprint !== false
};
let processedImage;
try {
const runProcessing = (lp) => secureImageService.processProtectedImage(lp, protectionSettings);
processedImage = storageKey
? await withLocalCopy(storageKey, runProcessing)
: await runProcessing(resolvePhotoFilePath(event, photo));
} catch (resolveError) {
logger.error('Failed to process secure image', {
slug: req.params.slug,
photoId,
eventId: event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Log successful access
await secureImageService.logImageAccess(
photoId,
event.id,
req.clientInfo,
'view'
);
// Set content type and security headers
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': processedImage.length,
'X-Protection-Level': protectionSettings.protectionLevel,
'X-Remaining-Uses': tokenValidation.remaining
});
res.send(processedImage);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure image', {
error: error.message,
photoId,
slug,
clientFingerprint: req.clientInfo?.fingerprint
});
res.status(500).json({ error: 'Failed to serve image' });
}
}
);
/**
* Download protected image with watermark
*/
router.get('/:slug/secure-download/:photoId/:token',
secureImageMiddleware.secureImageAccess,
async (req, res, next) => {
// Add slug to request for verifyGalleryAccess
req.requestedSlug = req.params.slug;
next();
},
verifyGalleryAccess,
blockHiddenGallery,
denySlideshowToken,
async (req, res) => {
try {
const { photoId, token } = req.params;
// Check if downloads are allowed. SQLite stores the flag as 0/1, so a
// strict `=== false` never fired there and the guard was inert (#1028).
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
// Verify secure token
const tokenValidation = secureImageService.verifySecureToken(
token,
req.clientInfo.fingerprint
);
if (!tokenValidation.valid) {
return res.status(403).json({ error: 'Invalid or expired token' });
}
await galleryAccessService.authorize(req.event, tokenValidation.data?.galleryAccess);
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Verify photo exists
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden/client-only photos.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Per-category download opt-out (#640) — the regular single-photo
// download enforces this too; the secure path skipped it. SQLite
// returns the boolean as numeric 0, so check both forms.
if (photo.category_id) {
const cat = await db('photo_categories')
.where('id', photo.category_id)
.first('allow_downloads');
if (cat && (cat.allow_downloads === false || cat.allow_downloads === 0)) {
return res.status(403).json({ error: 'Downloads are disabled for this category' });
}
}
// Resolve photo through storage backend (managed) or local disk (external).
const storageKey = resolvePhotoStorageKey(req.event, photo);
const watermarkService = require('../services/watermarkService');
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wantsWatermark = watermarkSettings && watermarkSettings.enabled;
let fileBuffer;
try {
if (wantsWatermark) {
fileBuffer = storageKey
? await withLocalCopy(storageKey, (lp) => watermarkService.applyWatermark(lp, watermarkSettings))
: await watermarkService.applyWatermark(resolvePhotoFilePath(req.event, photo), watermarkSettings);
} else if (storageKey) {
const stream = await getStorage().get(storageKey);
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
fileBuffer = Buffer.concat(chunks);
} else {
const fs = require('fs').promises;
fileBuffer = await fs.readFile(resolvePhotoFilePath(req.event, photo));
}
} catch (resolveError) {
logger.error('Failed to fetch photo for secure download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
// Log download
await secureImageService.logImageAccess(
photoId,
req.event.id,
req.clientInfo,
'download'
);
// #493/#507: respect the original-filename toggle here too. The
// regular `/gallery/:slug/download/:photoId` route already does
// this — secure-images was missed in the original PR and ran
// even when the admin had opted into original camera filenames.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': buildContentDisposition(downloadName),
'Content-Length': fileBuffer.length,
'X-Download-Protected': 'true'
});
res.send(fileBuffer);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error serving secure download', {
error: error.message,
photoId: req.params.photoId
});
res.status(500).json({ error: 'Failed to download image' });
}
}
);
/**
* Get security statistics for monitoring
*/
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
router.get('/security/stats', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get security statistics
const stats = {
middleware: secureImageMiddleware.getSecurityStatus(),
recentAccess: await getRecentAccessStats(),
suspiciousActivity: await getSuspiciousActivityStats()
};
res.json(stats);
} catch (error) {
if (error.isOperational) return res.status(error.statusCode).json({ error: error.message, code: error.code });
logger.error('Error getting security stats', { error: error.message });
res.status(500).json({ error: 'Failed to get security stats' });
}
});
/**
* Get recent access statistics
*/
async function getRecentAccessStats() {
try {
const hourAgo = new Date(Date.now() - 3600000).toISOString();
const stats = await db('image_access_logs')
.where('accessed_at', '>', hourAgo)
.select('access_type')
.count('* as count')
.groupBy('access_type');
return stats.reduce((acc, stat) => {
acc[stat.access_type] = parseInt(stat.count);
return acc;
}, {});
} catch (error) {
logger.error('Error getting recent access stats:', error);
return {};
}
}
/**
* Get suspicious activity statistics
*/
async function getSuspiciousActivityStats() {
try {
const hourAgo = new Date(Date.now() - 3600000).toISOString();
const suspiciousCount = await db('image_access_logs')
.where('accessed_at', '>', hourAgo)
.where('access_type', 'like', '%suspicious%')
.count('* as count')
.first();
const uniqueIPs = await db('image_access_logs')
.where('accessed_at', '>', hourAgo)
.countDistinct('client_ip as count')
.first();
return {
suspiciousEvents: parseInt(suspiciousCount.count),
uniqueIPs: parseInt(uniqueIPs.count)
};
} catch (error) {
logger.error('Error getting suspicious activity stats:', error);
return { suspiciousEvents: 0, uniqueIPs: 0 };
}
}
module.exports = router;