diff --git a/backend/server.js b/backend/server.js index 5ec4d19b..dbd9a8c7 100644 --- a/backend/server.js +++ b/backend/server.js @@ -359,8 +359,8 @@ async function initializeRateLimiters() { } // Note: Rate limiters will be initialized after database connection -app.use(express.json({ limit: '10gb' })); -app.use(express.urlencoded({ extended: true, limit: '10gb' })); +app.use(express.json({ limit: '50mb' })); +app.use(express.urlencoded({ extended: true, limit: '50mb' })); // Request logging for API routes (with timestamps) const apiRequestLogger = (req, res, next) => { @@ -386,8 +386,23 @@ app.use('/api/admin', sessionTimeoutMiddleware); // Middleware to set CORS headers for static files const setCorsHeaders = (req, res, next) => { - res.header('Access-Control-Allow-Origin', req.headers.origin || '*'); - res.header('Access-Control-Allow-Credentials', 'true'); + const origin = req.headers.origin; + const staticAllowedOrigins = [ + process.env.FRONTEND_URL || 'http://localhost:3005', + process.env.ADMIN_URL || 'http://localhost:3005' + ]; + if (process.env.NODE_ENV === 'development') { + staticAllowedOrigins.push( + 'http://localhost:5173', + 'http://localhost:3002', + 'http://localhost:3001', + 'http://localhost:3000' + ); + } + if (origin && staticAllowedOrigins.indexOf(origin) !== -1) { + res.header('Access-Control-Allow-Origin', origin); + res.header('Access-Control-Allow-Credentials', 'true'); + } res.header('Cross-Origin-Resource-Policy', 'cross-origin'); next(); }; @@ -451,19 +466,16 @@ app.get('/health', async (req, res) => { try { // Check database connectivity await db.raw('SELECT 1'); - - res.json({ - status: 'ok', - database: 'connected', - timestamp: new Date().toISOString() + + res.json({ + status: 'ok', + timestamp: new Date().toISOString() }); } catch (error) { logger.error('Health check failed:', error); - res.status(503).json({ - status: 'error', - database: 'disconnected', - error: error.message, - timestamp: new Date().toISOString() + res.status(503).json({ + status: 'error', + timestamp: new Date().toISOString() }); } }); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index d1d90bd9..383f5861 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -495,8 +495,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) => const offset = (page - 1) * limit; const search = req.query.search || ''; const status = req.query.status || 'all'; - const sortBy = req.query.sortBy || 'created_at'; - const sortOrder = req.query.sortOrder || 'desc'; + const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date']; + const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at'; + const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc'; // Build query let query = db('events'); diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index e02fc42c..b19daba6 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -727,7 +727,8 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => { try { const { eventId } = req.params; - const { category_id, type, search, sort = 'date', order = 'desc' } = req.query; + const { category_id, type, search, sort = 'date' } = req.query; + const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc'; let query = db('photos') .where({ 'photos.event_id': eventId }) diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 36d83a75..125b341a 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -122,6 +122,11 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) } }); + // Mask sensitive secrets before sending to client + if (settingsObject.security_recaptcha_secret_key) { + settingsObject.security_recaptcha_secret_key = '••••••••'; + } + res.json(settingsObject); } catch (error) { console.error('Settings fetch error:', error); @@ -160,6 +165,11 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req, } }); + // Mask sensitive secrets before sending to client + if (settingsObject.security_recaptcha_secret_key) { + settingsObject.security_recaptcha_secret_key = '••••••••'; + } + res.json(settingsObject); } catch (error) { console.error('Settings fetch error:', error); diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 9f164175..acbb18f5 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -437,7 +437,7 @@ async function performLocalBackup(config, files) { }; } -function buildRsyncCommand(config) { +function buildRsyncArgs(config) { const storagePath = getStoragePath(); const host = config.backup_rsync_host; const remotePath = config.backup_rsync_path; @@ -446,20 +446,21 @@ function buildRsyncCommand(config) { throw new Error('Rsync configuration incomplete'); } - const options = ['-avz', '--delete', '--stats']; + const args = ['-avz', '--delete', '--stats']; if (config.backup_rsync_ssh_key) { - options.push(`-e "ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no"`); + args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`); } const excludePatterns = config.backup_exclude_patterns || []; - excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`)); + excludePatterns.forEach(pattern => args.push('--exclude', pattern)); const source = `${storagePath}/`; const destination = config.backup_rsync_user ? `${config.backup_rsync_user}@${host}:${remotePath}` : `${host}:${remotePath}`; - return `rsync ${options.join(' ')} "${source}" "${destination}"`; + args.push(source, destination); + return args; } function parseRsyncStats(output) { @@ -479,9 +480,9 @@ function parseRsyncStats(output) { } async function performRsyncBackup(config, files) { - const command = buildRsyncCommand(config); - const execAsync = getExecAsync(); - const { stdout } = await execAsync(command); + const { spawnAsync } = require('../utils/safeExec'); + const rsyncArgs = buildRsyncArgs(config); + const { stdout } = await spawnAsync('rsync', rsyncArgs); const stats = parseRsyncStats(stdout); const backedUpFiles = files.map(file => file.relativePath); @@ -503,8 +504,7 @@ async function performRsyncBackup(config, files) { backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length, backedUpSize: totalSize, backedUpFiles, - backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`, - rsyncCommand: command + backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}` }; } diff --git a/backend/src/services/databaseBackup.js b/backend/src/services/databaseBackup.js index 5252036b..1de8af4d 100644 --- a/backend/src/services/databaseBackup.js +++ b/backend/src/services/databaseBackup.js @@ -1,9 +1,7 @@ const fs = require('fs').promises; const path = require('path'); -const { exec } = require('child_process'); -const { promisify } = require('util'); -const execAsync = promisify(exec); const crypto = require('crypto'); +const { spawnAsync, spawnToFile } = require('../utils/safeExec'); const zlib = require('zlib'); const { pipeline } = require('stream/promises'); const { createReadStream, createWriteStream } = require('fs'); @@ -163,10 +161,10 @@ class DatabaseBackupService { try { // Use SQLite's backup API for consistency - await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`); - + await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]); + // Verify the backup - const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`); + const verifyResult = await spawnAsync('sqlite3', [tempPath, 'PRAGMA integrity_check']); if (!verifyResult.stdout.includes('ok')) { throw new Error('Backup integrity check failed'); } @@ -192,14 +190,6 @@ class DatabaseBackupService { async createPostgreSQLBackup(outputPath, options = {}) { const { host, port, user, password, database } = knexConfig.connection; - // Build connection string with proper escaping - const connectionParts = [ - `host=${host}`, - `port=${port}`, - `dbname=${database}`, - `user=${user}` - ]; - // Set PGPASSWORD environment variable for security const env = { ...process.env }; if (password) { @@ -227,14 +217,17 @@ class DatabaseBackupService { pgDumpOptions.push('--compress=6'); } - const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`; - + const pgDumpArgs = [ + ...pgDumpOptions, + '-h', host, + '-p', String(port), + '-U', user, + '-d', database + ]; + try { - const { stderr } = await execAsync(command, { - env, - maxBuffer: 1024 * 1024 * 100 // 100MB buffer - }); - + const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env }); + // pg_dump writes progress to stderr, not an error if (stderr && !stderr.includes('dump complete')) { logger.warn('pg_dump warnings:', stderr); @@ -261,7 +254,7 @@ class DatabaseBackupService { try { if (this.dbType === 'sqlite') { // For SQLite, we can directly check integrity - const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`); + const result = await spawnAsync('sqlite3', [backupPath, 'PRAGMA integrity_check']); if (!result.stdout.includes('ok')) { throw new Error('Backup integrity check failed'); } diff --git a/backend/src/services/imageProcessor.js b/backend/src/services/imageProcessor.js index dae55103..dacb0ca1 100644 --- a/backend/src/services/imageProcessor.js +++ b/backend/src/services/imageProcessor.js @@ -120,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) { failOnError: false // Don't fail on minor issues }); + // Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.) + sharpInstance = sharpInstance.withMetadata(false); + // Apply resize with configured settings // For square thumbnails with 'cover' fit, we crop to center sharpInstance = sharpInstance.resize(settings.width, settings.height, { @@ -339,6 +342,9 @@ async function generateHeroImage(imagePath, options = {}) { failOnError: false }); + // Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.) + sharpInstance = sharpInstance.withMetadata(false); + // Resize to fit hero dimensions while maintaining aspect ratio // Use 'cover' to fill the hero area (crops if needed) sharpInstance = sharpInstance.resize(heroWidth, heroHeight, { diff --git a/backend/src/services/restoreService.js b/backend/src/services/restoreService.js index c50ac6e7..19f58ea7 100644 --- a/backend/src/services/restoreService.js +++ b/backend/src/services/restoreService.js @@ -4,9 +4,7 @@ const crypto = require('crypto'); const zlib = require('zlib'); const { pipeline } = require('stream/promises'); const { createReadStream, createWriteStream } = require('fs'); -const { exec } = require('child_process'); -const { promisify } = require('util'); -const execAsync = promisify(exec); +const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec'); const { db } = require('../database/db'); const knexConfig = require('../../knexfile'); const logger = require('../utils/logger'); @@ -418,12 +416,14 @@ class RestoreService { let availableBytes = 0; let diskCheckSucceeded = false; try { - const { exec } = require('child_process'); - const execAsync = promisify(exec); // Use root path as fallback if storage path doesn't exist yet const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/'); - const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`); - const parsed = parseInt(stdout.trim()); + const { stdout } = await spawnAsync('df', ['-k', checkPath]); + // Parse df output: last line, 4th column is available KB + const lines = stdout.trim().split('\n'); + const lastLine = lines[lines.length - 1]; + const columns = lastLine.trim().split(/\s+/); + const parsed = parseInt(columns[3]); if (!isNaN(parsed) && parsed > 0) { availableBytes = parsed * 1024; // Convert from KB to bytes diskCheckSucceeded = true; @@ -492,15 +492,12 @@ class RestoreService { if (this.dbType === 'sqlite') { const dbPath = knexConfig.connection.filename; - await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`); + await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]); } else { // PostgreSQL backup const { host, port, user, password, database } = knexConfig.connection; const env = { ...process.env, PGPASSWORD: password }; - await execAsync( - `pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`, - { env } - ); + await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env }); } // Compress database backup @@ -513,8 +510,8 @@ class RestoreService { this.log('info', 'Backing up current files...'); const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const filesBackupPath = path.join(backupPath, 'files.tar.gz'); - - await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`); + + await spawnAsync('tar', ['-czf', filesBackupPath, '-C', path.dirname(storagePath), path.basename(storagePath)]); } // Create backup manifest @@ -696,10 +693,10 @@ class RestoreService { try { // Restore from backup - await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`); - + await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]); + // Verify integrity - const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`); + const integrityCheck = await spawnAsync('sqlite3', [dbPath, 'PRAGMA integrity_check']); if (!integrityCheck.stdout.includes('ok')) { throw new Error('Database integrity check failed after restore'); } @@ -722,21 +719,12 @@ class RestoreService { // Drop and recreate database (extremely dangerous!) this.log('warn', 'Dropping and recreating PostgreSQL database...'); - await execAsync( - `psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`, - { env } - ); - - await execAsync( - `psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`, - { env } - ); - + await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env }); + + await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env }); + // Restore from backup - await execAsync( - `psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`, - { env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer - ); + await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env }); } // Re-initialize database connection @@ -987,14 +975,11 @@ class RestoreService { if (this.dbType === 'sqlite') { const dbPath = knexConfig.connection.filename; - await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`); + await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]); } else { const { host, port, user, password, database } = knexConfig.connection; const env = { ...process.env, PGPASSWORD: password }; - await execAsync( - `psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`, - { env } - ); + await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env }); } await fs.unlink(decompressedPath); @@ -1004,7 +989,7 @@ class RestoreService { const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz'); if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) { const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); - await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`); + await spawnAsync('tar', ['-xzf', filesBackupPath, '-C', path.dirname(storagePath)]); } this.log('info', 'Rollback completed successfully'); diff --git a/backend/src/utils/safeExec.js b/backend/src/utils/safeExec.js new file mode 100644 index 00000000..efba1ae7 --- /dev/null +++ b/backend/src/utils/safeExec.js @@ -0,0 +1,113 @@ +const { spawn } = require('child_process'); + +/** + * Safe command execution utilities using spawn (shell: false). + * These prevent command injection by never invoking a shell interpreter. + */ + +/** + * Run a command with arguments, returning { stdout, stderr }. + * Equivalent to execAsync(cmd) but safe from injection. + */ +function spawnAsync(cmd, args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + shell: false, + ...options, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const stdoutChunks = []; + const stderrChunks = []; + + child.stdout.on('data', chunk => stdoutChunks.push(chunk)); + child.stderr.on('data', chunk => stderrChunks.push(chunk)); + + child.on('error', reject); + child.on('close', (code) => { + const stdout = Buffer.concat(stdoutChunks).toString(); + const stderr = Buffer.concat(stderrChunks).toString(); + if (code !== 0) { + const err = new Error(`${cmd} exited with code ${code}: ${stderr}`); + err.code = code; + err.stdout = stdout; + err.stderr = stderr; + return reject(err); + } + resolve({ stdout, stderr }); + }); + }); +} + +/** + * Run a command and redirect stdout to a file (replaces shell `> file`). + */ +function spawnToFile(cmd, args, outputPath, options = {}) { + const fs = require('fs'); + return new Promise((resolve, reject) => { + const outStream = fs.createWriteStream(outputPath); + const child = spawn(cmd, args, { + shell: false, + ...options, + stdio: ['ignore', outStream, 'pipe'] + }); + + const stderrChunks = []; + child.stderr.on('data', chunk => stderrChunks.push(chunk)); + + child.on('error', (err) => { + outStream.destroy(); + reject(err); + }); + child.on('close', (code) => { + outStream.end(); + const stderr = Buffer.concat(stderrChunks).toString(); + if (code !== 0) { + const err = new Error(`${cmd} exited with code ${code}: ${stderr}`); + err.code = code; + err.stderr = stderr; + return reject(err); + } + resolve({ stderr }); + }); + }); +} + +/** + * Run a command and pipe a file into stdin (replaces shell `< file`). + */ +function spawnFromFile(cmd, args, inputPath, options = {}) { + const fs = require('fs'); + return new Promise((resolve, reject) => { + const inStream = fs.createReadStream(inputPath); + const child = spawn(cmd, args, { + shell: false, + ...options, + stdio: [inStream, 'pipe', 'pipe'] + }); + + const stdoutChunks = []; + const stderrChunks = []; + child.stdout.on('data', chunk => stdoutChunks.push(chunk)); + child.stderr.on('data', chunk => stderrChunks.push(chunk)); + + child.on('error', (err) => { + inStream.destroy(); + reject(err); + }); + child.on('close', (code) => { + const stdout = Buffer.concat(stdoutChunks).toString(); + const stderr = Buffer.concat(stderrChunks).toString(); + if (code !== 0) { + const err = new Error(`${cmd} exited with code ${code}: ${stderr}`); + err.code = code; + err.stdout = stdout; + err.stderr = stderr; + return reject(err); + } + resolve({ stdout, stderr }); + }); + }); +} + +module.exports = { spawnAsync, spawnToFile, spawnFromFile }; diff --git a/docker-compose.yml b/docker-compose.yml index 74a5e75d..a4cc168b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,7 +70,7 @@ services: volumes: - postgres-data:/var/lib/postgresql/data ports: - - "${DB_PORT:-5432}:5432" + - "127.0.0.1:${DB_PORT:-5432}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"] interval: 10s @@ -89,7 +89,7 @@ services: volumes: - redis-data:/data ports: - - "${REDIS_PORT:-6379}:6379" + - "127.0.0.1:${REDIS_PORT:-6379}:6379" healthcheck: test: ["CMD", "redis-cli", "--raw", "incr", "ping"] interval: 10s diff --git a/frontend/nginx.conf b/frontend/nginx.conf index dddd2535..7fe4c5df 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,6 +1,7 @@ server { listen 80; server_name localhost; + server_tokens off; root /usr/share/nginx/html; index index.html; @@ -21,9 +22,9 @@ server { # Security headers add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "no-referrer-when-downgrade" always; - add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" 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; # Health check endpoint location /health { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 80aae11a..66c2c0bd 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -17,7 +17,7 @@ const config: VitestUserConfig = { }, }, }, - sourcemap: true, + sourcemap: false, }, test: { environment: 'jsdom',