security: comprehensive hardening across frontend, backend, and infrastructure

- Disable production source maps and hide nginx version
- Reduce JSON body limit from 10gb to 50mb (uploads use multer, not JSON)
- Strip database info and error details from health endpoint
- Mask reCAPTCHA secret key in admin settings API responses
- Whitelist sort/order query parameters in events and photos endpoints
- Stop reflecting arbitrary origins in static file CORS headers
- Align nginx security headers with backend Helmet CSP, remove deprecated X-XSS-Protection
- Strip EXIF metadata from generated thumbnails and hero images
- Bind postgres/redis dev ports to localhost in docker-compose configs
- Add safeExec utility (spawn with shell:false) to prevent command injection
- Convert all exec/execAsync calls in backup, restore, and database backup
  services to use safe spawn-based helpers
This commit is contained in:
Paul Nothaft
2026-02-16 22:33:20 +01:00
parent 50c09904a9
commit 2b25d81144
12 changed files with 214 additions and 92 deletions
+18 -6
View File
@@ -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 || '*');
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();
};
@@ -454,15 +469,12 @@ app.get('/health', async (req, res) => {
res.json({
status: 'ok',
database: 'connected',
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()
});
}
+3 -2
View File
@@ -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');
+2 -1
View File
@@ -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 })
+10
View File
@@ -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);
+10 -10
View File
@@ -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}`
};
}
+12 -19
View File
@@ -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,13 +217,16 @@ 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')) {
@@ -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');
}
+6
View File
@@ -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, {
+18 -33
View File
@@ -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
@@ -514,7 +511,7 @@ class RestoreService {
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 spawnAsync('psql', ['-h', host, '-p', String(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', `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');
+113
View File
@@ -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 };
+2 -2
View File
@@ -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
+4 -3
View File
@@ -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 {
+1 -1
View File
@@ -17,7 +17,7 @@ const config: VitestUserConfig = {
},
},
},
sourcemap: true,
sourcemap: false,
},
test: {
environment: 'jsdom',