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
+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}`
};
}
+15 -22
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,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');
}
+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, {
+22 -37
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
@@ -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');