fix: address Shannon security assessment findings (37 vulnerabilities) (#254)

Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities
identified in the Shannon security assessment (2026-03-20).

Critical fixes:
- Command injection via rsync SSH key path (INJ-VULN-01)
- Self-escalation to super_admin role (AUTHZ-VULN-11)
- Invite super_admin backdoor (AUTHZ-VULN-12)
- Handlebars SSTI in email templates (INJ-VULN-05)

Authentication hardening:
- Rate limit on share-link login (AUTH-VULN-01)
- X-Forwarded-For spoofing bypass (AUTH-VULN-02)
- reCAPTCHA fails closed when misconfigured (AUTH-VULN-03)
- Token revocation on admin/gallery logout (AUTH-VULN-04/05)
- Cookie Secure flag defaults true in production (AUTH-VULN-06)
- Remove JWT from admin login response body (AUTH-VULN-07)
- Timing-safe gallery slug validation (AUTH-VULN-09)
- Account lockout fails closed on DB error (AUTH-VULN-12)
- Session endpoint checks token revocation

Path traversal & file access:
- checksums endpoint path containment (INJ-VULN-03)
- manifest validate path containment (INJ-VULN-04)

XSS prevention:
- Block SVG data URIs in CSS sanitizer (XSS-VULN-01)
- Email preview iframe sandbox (XSS-VULN-02)
- SSR branding HTML escaping (XSS-VULN-03)
- User-Agent sanitization in feedback (XSS-VULN-04)

Authorization (IDOR):
- Event ownership middleware for all admin routes
- Cross-admin user profile read restriction (AUTHZ-VULN-10)

SSRF & infrastructure:
- Private IP validation for SMTP, S3, rsync hosts
- Replace inline JWT with standard adminAuth middleware
- CSRF Content-Type enforcement on mutating API endpoints
- CSP headers in nginx location blocks

Token revocation fix:
- Remove overly broad orWhere clause that invalidated all future tokens
- Allow empty-body POST requests (logout) in CSRF middleware

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-03-22 12:40:01 +01:00
committed by GitHub
co-authored by Paul Nothaft
parent a63f1a8dd9
commit 23cd9cb680
27 changed files with 425 additions and 169 deletions
+5 -4
View File
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
// Get all archived events
@@ -82,7 +83,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
});
// Get single archive details
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -138,7 +139,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, re
});
// Restore archive
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -301,7 +302,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), as
});
// Download archive
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
router.get('/:id/download', adminAuth, requirePermission('archives.download'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -350,7 +351,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), a
});
// Delete archive permanently
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
+30 -9
View File
@@ -258,6 +258,13 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
// Validate username format if provided
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
res.json({ success: false, message: 'Invalid username format' });
@@ -355,12 +362,17 @@ router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view')
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath } = req.body;
if (!manifestPath) {
return res.status(400).json({ error: 'manifestPath is required' });
}
const result = await validateBackupManifest(manifestPath);
// Prevent path traversal — manifest must be within backup directory
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const safePath = safePathJoin(backupBasePath, manifestPath);
const result = await validateBackupManifest(safePath);
res.json({
valid: result.valid,
@@ -456,19 +468,24 @@ router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
if (!manifestPath && !manifestData) {
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
}
if (manifestData) {
// Validate provided manifest data directly
const validationResult = await validateManifestData(manifestData);
return res.json(validationResult);
}
// Prevent path traversal — manifest must be within backup directory
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const safePath = safePathJoin(backupBasePath, manifestPath);
// Use existing validation function for path
const result = await validateBackupManifest(manifestPath);
const result = await validateBackupManifest(safePath);
res.json({
valid: result.valid,
@@ -757,10 +774,14 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
let basePath = storagePath;
if (targetPath) {
const { safePathJoin } = require('../utils/fileSecurityUtils');
basePath = safePathJoin(storagePath, targetPath);
}
// Calculate checksums for files
async function calculateDirChecksums(dirPath, relative = '') {
+17 -3
View File
@@ -61,6 +61,12 @@ router.post('/config', [
tls_reject_unauthorized
} = req.body;
// Validate SMTP host is not a private/internal address (SSRF protection)
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(smtp_host)) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
// Check if config exists
const existingConfig = await db('email_configs').first();
@@ -455,11 +461,19 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
let subject = template[subjectField] || template.subject || '';
if (preview_data) {
const escapeHtml = (str) => String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
Object.keys(preview_data).forEach(key => {
const regex = new RegExp(`{{${key}}}`, 'g');
htmlContent = htmlContent.replace(regex, preview_data[key]);
textContent = textContent.replace(regex, preview_data[key]);
subject = subject.replace(regex, preview_data[key]);
const escapedValue = escapeHtml(preview_data[key]);
htmlContent = htmlContent.replace(regex, escapedValue);
textContent = textContent.replace(regex, preview_data[key]); // text doesn't need HTML escaping
subject = subject.replace(regex, escapeHtml(preview_data[key]));
});
}
+9 -8
View File
@@ -20,6 +20,7 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
@@ -680,7 +681,7 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
});
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), [
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
@@ -926,7 +927,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
});
// Delete event
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1017,7 +1018,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
});
// Toggle event status
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1057,7 +1058,7 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), a
});
// Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
@@ -1124,7 +1125,7 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1208,7 +1209,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
});
// Archive event
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1318,7 +1319,7 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
});
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
@@ -1373,7 +1374,7 @@ router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoU
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
+6
View File
@@ -12,11 +12,13 @@ const {
validateWordFilter,
checkValidation
} = require('../utils/feedbackValidation');
const { requireEventOwnership } = require('../middleware/ownership');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -42,6 +44,7 @@ router.get('/events/:eventId/feedback-settings',
router.put('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
validateEventId,
validateFeedbackSettings,
checkValidation,
@@ -79,6 +82,7 @@ router.put('/events/:eventId/feedback-settings',
router.get('/events/:eventId/feedback',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -204,6 +208,7 @@ router.delete('/feedback/:feedbackId',
router.get('/events/:eventId/feedback-analytics',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -304,6 +309,7 @@ router.get('/events/:eventId/feedback-analytics',
router.get('/events/:eventId/feedback/export',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
+16 -15
View File
@@ -14,6 +14,7 @@ const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploa
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
// Get storage path from environment or default
@@ -120,7 +121,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Upload photos for an event
// Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
@@ -522,7 +523,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
});
// Delete a photo
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -584,7 +585,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
});
// Update a photo (e.g., change category)
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id, visibility } = req.body;
@@ -647,7 +648,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
});
// Bulk delete photos
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds } = req.body;
@@ -720,7 +721,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
});
// Bulk update photos
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
@@ -783,7 +784,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
});
// Download a photo
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -815,7 +816,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
});
// Get all photos for an event
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date' } = req.query;
@@ -914,7 +915,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
});
// Serve photo with admin authentication
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -951,7 +952,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
});
// Serve thumbnail with admin authentication
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -991,7 +992,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
@@ -1017,7 +1018,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async
// ============================================
// Initialize a chunked upload
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body;
@@ -1055,7 +1056,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
});
// Upload a chunk
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { uploadId, chunkIndex } = req.params;
@@ -1076,7 +1077,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
});
// Complete chunked upload and process the file
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { eventId, uploadId } = req.params;
const { category_id } = req.body;
@@ -1118,7 +1119,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
});
// Get upload status
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { uploadId } = req.params;
@@ -1136,7 +1137,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
});
// Abort chunked upload
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
try {
const { uploadId } = req.params;
+10 -3
View File
@@ -114,7 +114,8 @@ router.post('/invite', [
const invitation = await userManagementService.createInvitation({
email: req.body.email,
roleId: req.body.role_id,
invitedById: req.admin.id
invitedById: req.admin.id,
inviterRoleName: req.admin.roleName
});
successResponse(res, { invitation }, 201);
@@ -146,7 +147,12 @@ router.get('/:id', [
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
const targetId = parseInt(req.params.id);
// Non-super_admin users can only view their own profile
if (req.admin.roleName !== 'super_admin' && targetId !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
const user = await userManagementService.getAdminUserById(targetId);
res.json({ user: transformUser(user) });
}));
@@ -169,7 +175,8 @@ router.put('/:id', [
const user = await userManagementService.updateAdminUser(
parseInt(req.params.id),
req.body,
req.admin.id
req.admin.id,
{ roleName: req.admin.roleName }
);
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
+32 -6
View File
@@ -13,6 +13,7 @@ const {
getGenericAuthError
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const { revokeToken } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
@@ -117,9 +118,8 @@ router.post('/admin/login', [
setAdminAuthCookie(res, token);
// Include role in response
// Token is delivered via HttpOnly cookie only (not in response body)
res.json({
token,
user: {
id: admin.id,
username: admin.username,
@@ -145,7 +145,8 @@ router.post('/logout', async (req, res) => {
const token = adminToken || galleryToken;
if (token) {
// End the session
// Revoke the token so it can't be reused, then end the session
await revokeToken(token, 'user_logout');
endSession(token);
try {
@@ -198,6 +199,8 @@ router.post('/gallery/verify', [
.first();
if (!event) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
@@ -381,6 +384,17 @@ router.post('/gallery/share-login', [
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Rate limit share-link login attempts
const shareIdentifier = `gallery:${slug}:share`;
const lockoutStatus = await checkAccountLockout(shareIdentifier, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Share link login attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
@@ -393,12 +407,14 @@ router.post('/gallery/share-login', [
}
if (!event) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid or expired share link' });
}
@@ -440,10 +456,14 @@ router.post('/gallery/share-login', [
}
});
// Gallery logout to clear cookies
// Gallery logout to clear cookies and revoke token
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
const token = getGalleryTokenFromRequest(req, slug);
if (token) {
await revokeToken(token, 'gallery_logout');
}
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
@@ -464,11 +484,17 @@ router.get('/session', async (req, res) => {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token has been revoked (e.g. after logout)
const { isTokenRevoked } = require('../utils/tokenRevocation');
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
}
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
+1 -1
View File
@@ -204,7 +204,7 @@ router.post('/:slug/photos/:photoId/feedback',
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.headers['user-agent'],
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
moderate_comments: settings.moderate_comments
};
+4 -27
View File
@@ -350,34 +350,11 @@ router.get('/:slug/secure-download/:photoId/:token',
/**
* Get security statistics for monitoring
*/
router.get('/security/stats', async (req, res) => {
try {
// Only allow admin access
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const jwt = require('jsonwebtoken');
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth'
});
} catch (issuerError) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw issuerError;
}
}
const admin = await db('admin_users').where({ id: decoded.id }).first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
router.get('/security/stats', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get security statistics
const stats = {