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 <paul@MacStudio-von-Paul.local>
This commit is contained in:
Paul Nothaft
2026-03-22 12:40:01 +01:00
committed by GitHub
parent a63f1a8dd9
commit 23cd9cb680
27 changed files with 425 additions and 169 deletions
+33 -8
View File
@@ -194,13 +194,23 @@ function composeInlineStyles(payload) {
return cssSegments.join('\n\n');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderBrandHeader(branding) {
const displayName = branding.companyName || 'PicPeak';
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
const displayName = escapeHtml(branding.companyName || 'PicPeak');
const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png');
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
const tagline = branding.companyTagline
? `<p class="brand-tagline">${branding.companyTagline}</p>`
? `<p class="brand-tagline">${escapeHtml(branding.companyTagline)}</p>`
: '';
return `<header class="site-header">
@@ -224,13 +234,14 @@ function renderBrandHeader(branding) {
}
function renderBrandFooter(branding) {
const displayName = branding.companyName || 'PicPeak';
const displayName = escapeHtml(branding.companyName || 'PicPeak');
const footerNote = branding.footerText
? `<p>${branding.footerText}</p>`
? `<p>${escapeHtml(branding.footerText)}</p>`
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
const supportLink = branding.supportEmail
? `<a href="mailto:${branding.supportEmail}">Support</a>`
const supportEmail = escapeHtml(branding.supportEmail || '');
const supportLink = supportEmail
? `<a href="mailto:${supportEmail}">Support</a>`
: '';
const legalLinks = `
@@ -282,7 +293,7 @@ function buildPublicSiteDocument(payload) {
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${payload.title}</title>
<title>${escapeHtml(payload.title)}</title>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
${seoMeta}
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -362,6 +373,20 @@ async function initializeRateLimiters() {
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
}
next();
});
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
+35
View File
@@ -0,0 +1,35 @@
const { db } = require('../database/db');
/**
* Middleware to enforce event ownership for non-super_admin users.
* Super admins bypass the check. Other admins can only access events they created.
*/
function requireEventOwnership(req, res, next) {
if (req.admin.roleName === 'super_admin') {
return next();
}
const eventId = req.params.eventId || req.params.id;
if (!eventId) {
return res.status(400).json({ error: 'Event ID is required' });
}
db('events')
.where('id', eventId)
.first()
.then((event) => {
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Allow access if: event has no owner (legacy/system), or admin owns it
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
next();
})
.catch((err) => {
res.status(500).json({ error: 'Failed to verify ownership' });
});
}
module.exports = { requireEventOwnership };
+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 = {
+38 -5
View File
@@ -437,26 +437,59 @@ async function performLocalBackup(config, files) {
};
}
function validateRsyncParam(value, label) {
if (!value || typeof value !== 'string') return null;
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
throw new Error(`Invalid ${label}: contains disallowed characters`);
}
if (value.length > 1024) {
throw new Error(`Invalid ${label}: too long`);
}
return value;
}
function buildRsyncArgs(config) {
const storagePath = getStoragePath();
const host = config.backup_rsync_host;
const remotePath = config.backup_rsync_path;
const host = validateRsyncParam(config.backup_rsync_host, 'host');
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
if (!host || !remotePath) {
throw new Error('Rsync configuration incomplete');
}
// Validate host format (hostname or IP only)
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!hostRegex.test(host) && !ipRegex.test(host)) {
throw new Error('Invalid rsync host format');
}
const args = ['-avz', '--delete', '--stats'];
if (config.backup_rsync_ssh_key) {
args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`);
const sshKey = validateRsyncParam(config.backup_rsync_ssh_key, 'SSH key path');
const fs = require('fs');
if (!fs.existsSync(sshKey) || !fs.statSync(sshKey).isFile()) {
throw new Error('SSH key file not found or is not a file');
}
// Pass SSH options as separate array elements to avoid shell interpretation
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
}
const excludePatterns = config.backup_exclude_patterns || [];
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
const source = `${storagePath}/`;
const destination = config.backup_rsync_user
? `${config.backup_rsync_user}@${host}:${remotePath}`
const user = config.backup_rsync_user;
if (user) {
validateRsyncParam(user, 'user');
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
throw new Error('Invalid rsync username format');
}
}
const destination = user
? `${user}@${host}:${remotePath}`
: `${host}:${remotePath}`;
args.push(source, destination);
+9 -8
View File
@@ -345,15 +345,16 @@ async function processTemplate(template, variables, language = 'en') {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Compile templates with Handlebars
const subjectTemplate = Handlebars.compile(subject);
const htmlTemplate = Handlebars.compile(htmlBody);
const textTemplate = Handlebars.compile(textBody);
// Safe template replacement (no code execution, only simple variable substitution)
function safeTemplateReplace(template, variables) {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
variables.hasOwnProperty(key) ? String(variables[key]) : match
);
}
// Process templates with processedVariables (includes formatted dates and security messages)
subject = subjectTemplate(processedVariables);
htmlBody = htmlTemplate(processedVariables);
textBody = textTemplate(processedVariables);
subject = safeTemplateReplace(subject, processedVariables);
htmlBody = safeTemplateReplace(htmlBody, processedVariables);
textBody = safeTemplateReplace(textBody, processedVariables);
// Inject client access section if client_link is provided (#172)
if (processedVariables.client_link) {
+4 -22
View File
@@ -163,22 +163,13 @@ async function createRateLimiter() {
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
},
keyGenerator: (req) => {
// Use correct client IP when behind proxy
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
keyGenerator: (req) => req.ip,
skip: async (req) => {
const currentConfig = await getRateLimitSettings();
return shouldSkipRateLimit(req, currentConfig);
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
const clientIp = req.ip;
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
@@ -223,22 +214,13 @@ async function createAuthRateLimiter() {
return rateLimit({
windowMs: config.windowMinutes * 60 * 1000,
max: config.authMaxRequests,
keyGenerator: (req) => {
// Use correct client IP when behind proxy
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
keyGenerator: (req) => req.ip,
skip: async () => {
const currentConfig = await getRateLimitSettings();
return !currentConfig.enabled;
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
const clientIp = req.ip;
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
+3 -3
View File
@@ -30,10 +30,10 @@ async function verifyRecaptcha(token) {
return false;
}
// If no secret key configured, log warning but pass
// If no secret key configured, fail closed
if (!secretKey) {
console.warn('reCAPTCHA enabled but no secret key configured');
return true;
console.warn('reCAPTCHA enabled but no secret key configured — blocking request');
return false;
}
try {
+10
View File
@@ -85,6 +85,16 @@ class S3StorageAdapter extends stream.EventEmitter {
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
}
// SSRF protection: block private/internal S3 endpoints in production
// Local endpoints (e.g. MinIO on localhost) are allowed in development
if (process.env.NODE_ENV === 'production') {
const { validateExternalUrl } = require('../../utils/networkValidation');
const urlCheck = validateExternalUrl(endpoint);
if (!urlCheck.valid) {
throw new Error(`Invalid S3 endpoint: ${urlCheck.error}`);
}
}
s3Config.endpoint = endpoint;
// For S3-compatible services with custom endpoints, force path style
+35 -2
View File
@@ -18,7 +18,7 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
* @param {object} params - { email, roleId, invitedById }
* @returns {Promise<object>} Created invitation details
*/
async function createInvitation({ email, roleId, invitedById }) {
async function createInvitation({ email, roleId, invitedById, inviterRoleName }) {
// Check if email already exists
const existingUser = await db('admin_users').where('email', email).first();
if (existingUser) {
@@ -42,6 +42,11 @@ async function createInvitation({ email, roleId, invitedById }) {
throw new NotFoundError('Role', roleId);
}
// Role hierarchy: only super_admin can invite super_admin
if (role.name === 'super_admin' && inviterRoleName !== 'super_admin') {
throw new ValidationError('Only Super Admins can invite new Super Admins');
}
// Generate secure invitation token (64 characters hex = 32 bytes)
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
@@ -213,7 +218,7 @@ async function getAdminUserById(id) {
* @param {number} updatedById - ID of user making the update
* @returns {Promise<object>} Updated user
*/
async function updateAdminUser(id, updates, updatedById) {
async function updateAdminUser(id, updates, updatedById, requestingAdmin = {}) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
@@ -248,6 +253,34 @@ async function updateAdminUser(id, updates, updatedById) {
if (!role) {
throw new NotFoundError('Role', updates.role_id);
}
// Role hierarchy enforcement
const superAdminRole = await db('roles').where('name', 'super_admin').first();
const isSuperAdmin = requestingAdmin.roleName === 'super_admin';
// Only super_admin can assign super_admin role
if (superAdminRole && role.id === superAdminRole.id && !isSuperAdmin) {
throw new ValidationError('Only Super Admins can assign the Super Admin role');
}
// Prevent self-role-update
if (id === updatedById) {
throw new ValidationError('Cannot change your own role');
}
// Prevent downgrading the last super_admin
if (superAdminRole && user.role_id === superAdminRole.id && role.id !== superAdminRole.id) {
const superAdminCount = await db('admin_users')
.where('role_id', superAdminRole.id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (Number(superAdminCount?.count) <= 1) {
throw new ValidationError('Cannot demote the last Super Admin');
}
}
allowedUpdates.role_id = updates.role_id;
}
+1 -1
View File
@@ -261,7 +261,7 @@ async function checkAccountLockout(identifier, ipAddress) {
return { isLocked: false };
} catch (error) {
logger.error('Error checking account lockout:', error);
return { isLocked: false }; // Fail open to avoid locking users out due to errors
return { isLocked: true, remainingTime: 300 }; // Fail closed on DB error
}
}
+4 -3
View File
@@ -29,8 +29,8 @@ const FORBIDDEN_PATTERNS = [
/on\w+\s*=/gi, // onclick=, onload=, etc.
];
// Pattern for external URLs (block external, allow data: for images)
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image)/gi;
// Pattern for external URLs (block external, allow only safe raster data: images)
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image\/(?:jpeg|jpg|png|gif|webp))/gi;
// Maximum CSS size in bytes (100KB)
const MAX_CSS_SIZE = 100 * 1024;
@@ -50,7 +50,8 @@ function sanitizeCss(css) {
/@charset[^;]+;?/gi,
/expression\s*\([^)]*\)/gi,
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi,
/url\s*\(\s*(['"]?)\s*data:image\/svg\+xml[^)]*\)/gi
];
disallowedPatterns.forEach((pattern) => {
+97
View File
@@ -0,0 +1,97 @@
const { URL } = require('url');
const net = require('net');
/**
* Check if a hostname or IP resolves to a private/internal network address.
* Blocks SSRF attempts targeting internal infrastructure.
*/
function isPrivateIP(hostname) {
if (!hostname || typeof hostname !== 'string') return true;
const lower = hostname.toLowerCase().trim();
// Block known metadata / loopback hostnames
const blockedHostnames = [
'localhost',
'metadata.google.internal',
'metadata.google',
'169.254.169.254',
'0.0.0.0',
'::1',
'[::1]',
];
if (blockedHostnames.includes(lower)) return true;
// If it's an IP address, check ranges directly
if (net.isIPv4(lower)) {
return isPrivateIPv4(lower);
}
// IPv6 checks
if (net.isIPv6(lower) || lower.startsWith('[')) {
const cleanIp = lower.replace(/^\[|\]$/g, '');
return isPrivateIPv6(cleanIp);
}
// Hostname patterns that resolve to internal services
if (lower.endsWith('.internal') || lower.endsWith('.local') || lower.endsWith('.localhost')) {
return true;
}
return false;
}
function isPrivateIPv4(ip) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some(p => isNaN(p))) return true;
const [a, b] = parts;
// 127.0.0.0/8 — loopback
if (a === 127) return true;
// 10.0.0.0/8 — private
if (a === 10) return true;
// 172.16.0.0/12 — private
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 — private
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 — link-local
if (a === 169 && b === 254) return true;
// 0.0.0.0/8
if (a === 0) return true;
return false;
}
function isPrivateIPv6(ip) {
const lower = ip.toLowerCase();
// ::1 loopback
if (lower === '::1' || lower === '0000:0000:0000:0000:0000:0000:0000:0001') return true;
// fc00::/7 — unique local
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
// fe80::/10 — link-local
if (lower.startsWith('fe80')) return true;
// :: unspecified
if (lower === '::') return true;
return false;
}
/**
* Validate a URL string, rejecting private/internal targets.
* @param {string} urlString - URL to validate
* @returns {{ valid: boolean, error?: string }}
*/
function validateExternalUrl(urlString) {
try {
const parsed = new URL(urlString);
if (isPrivateIP(parsed.hostname)) {
return { valid: false, error: 'URL points to a private or internal network address' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid URL format' };
}
}
module.exports = { isPrivateIP, validateExternalUrl };
+11 -11
View File
@@ -213,17 +213,17 @@ async function validatePasswordInContext(password, context, userData = {}) {
// Base validation with gallery-specific options
const result = validatePassword(password, galleryOptions);
// Override validation for common date formats
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
if (datePattern.test(password)) {
// Date format is valid for gallery passwords
return {
valid: true,
errors: [],
score: 2,
feedback: {}
};
// Only allow date-format passwords when complexity is 'simple'
if (complexityLevel === 'simple') {
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
if (datePattern.test(password)) {
return {
valid: true,
errors: [],
score: 2,
feedback: {}
};
}
}
// Additional gallery-specific checks
+2 -22
View File
@@ -9,28 +9,8 @@ function getClientIp(req) {
if (!req) {
return '';
}
const forwardedFor = req.headers['x-forwarded-for'];
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
if (firstIp) {
return firstIp;
}
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
const [firstIp] = forwardedFor;
if (firstIp) {
return firstIp.trim();
}
}
return (
req.ip ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.connection?.socket?.remoteAddress ||
''
);
// Use req.ip which respects Express 'trust proxy' setting
return req.ip || req.connection?.remoteAddress || '';
}
module.exports = { getClientIp };
-5
View File
@@ -57,11 +57,6 @@ async function isTokenRevoked(decodedToken) {
const revoked = await db('revoked_tokens')
.where('token_id', tokenId)
.orWhere((builder) => {
builder
.where('user_id', decodedToken.id)
.where('revoked_at', '<=', new Date(decodedToken.iat * 1000).toISOString());
})
.first();
return !!revoked;
+2 -3
View File
@@ -8,9 +8,8 @@ const secureCookie = (() => {
if (typeof process.env.COOKIE_SECURE === 'string') {
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
}
// Default to false so native HTTP installs stay functional. Operators can
// opt-in via COOKIE_SECURE=true when serving behind HTTPS.
return false;
// Default to true in production (HTTPS expected), false in development
return process.env.NODE_ENV === 'production';
})();
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = process.env.COOKIE_DOMAIN;
+10
View File
@@ -37,6 +37,11 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# Re-apply security headers (add_header in location block overrides server-level)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# Cache index.html with revalidation
@@ -44,6 +49,11 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
# Re-apply security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# API proxy
@@ -76,6 +76,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
) : (