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:
+19
-7
@@ -359,8 +359,8 @@ async function initializeRateLimiters() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Note: Rate limiters will be initialized after database connection
|
// Note: Rate limiters will be initialized after database connection
|
||||||
app.use(express.json({ limit: '10gb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
|
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
|
|
||||||
// Request logging for API routes (with timestamps)
|
// Request logging for API routes (with timestamps)
|
||||||
const apiRequestLogger = (req, res, next) => {
|
const apiRequestLogger = (req, res, next) => {
|
||||||
@@ -386,8 +386,23 @@ app.use('/api/admin', sessionTimeoutMiddleware);
|
|||||||
|
|
||||||
// Middleware to set CORS headers for static files
|
// Middleware to set CORS headers for static files
|
||||||
const setCorsHeaders = (req, res, next) => {
|
const setCorsHeaders = (req, res, next) => {
|
||||||
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
|
const origin = req.headers.origin;
|
||||||
res.header('Access-Control-Allow-Credentials', 'true');
|
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');
|
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
@@ -454,15 +469,12 @@ app.get('/health', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
database: 'connected',
|
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Health check failed:', error);
|
logger.error('Health check failed:', error);
|
||||||
res.status(503).json({
|
res.status(503).json({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
database: 'disconnected',
|
|
||||||
error: error.message,
|
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -495,8 +495,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
|||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
const search = req.query.search || '';
|
const search = req.query.search || '';
|
||||||
const status = req.query.status || 'all';
|
const status = req.query.status || 'all';
|
||||||
const sortBy = req.query.sortBy || 'created_at';
|
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
|
||||||
const sortOrder = req.query.sortOrder || 'desc';
|
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
|
// Build query
|
||||||
let query = db('events');
|
let query = db('events');
|
||||||
|
|||||||
@@ -727,7 +727,8 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
|
|||||||
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { eventId } = req.params;
|
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')
|
let query = db('photos')
|
||||||
.where({ 'photos.event_id': eventId })
|
.where({ 'photos.event_id': eventId })
|
||||||
|
|||||||
@@ -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);
|
res.json(settingsObject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Settings fetch error:', 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);
|
res.json(settingsObject);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Settings fetch error:', error);
|
console.error('Settings fetch error:', error);
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ async function performLocalBackup(config, files) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRsyncCommand(config) {
|
function buildRsyncArgs(config) {
|
||||||
const storagePath = getStoragePath();
|
const storagePath = getStoragePath();
|
||||||
const host = config.backup_rsync_host;
|
const host = config.backup_rsync_host;
|
||||||
const remotePath = config.backup_rsync_path;
|
const remotePath = config.backup_rsync_path;
|
||||||
@@ -446,20 +446,21 @@ function buildRsyncCommand(config) {
|
|||||||
throw new Error('Rsync configuration incomplete');
|
throw new Error('Rsync configuration incomplete');
|
||||||
}
|
}
|
||||||
|
|
||||||
const options = ['-avz', '--delete', '--stats'];
|
const args = ['-avz', '--delete', '--stats'];
|
||||||
if (config.backup_rsync_ssh_key) {
|
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 || [];
|
const excludePatterns = config.backup_exclude_patterns || [];
|
||||||
excludePatterns.forEach(pattern => options.push(`--exclude="${pattern}"`));
|
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||||
|
|
||||||
const source = `${storagePath}/`;
|
const source = `${storagePath}/`;
|
||||||
const destination = config.backup_rsync_user
|
const destination = config.backup_rsync_user
|
||||||
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
||||||
: `${host}:${remotePath}`;
|
: `${host}:${remotePath}`;
|
||||||
|
|
||||||
return `rsync ${options.join(' ')} "${source}" "${destination}"`;
|
args.push(source, destination);
|
||||||
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRsyncStats(output) {
|
function parseRsyncStats(output) {
|
||||||
@@ -479,9 +480,9 @@ function parseRsyncStats(output) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function performRsyncBackup(config, files) {
|
async function performRsyncBackup(config, files) {
|
||||||
const command = buildRsyncCommand(config);
|
const { spawnAsync } = require('../utils/safeExec');
|
||||||
const execAsync = getExecAsync();
|
const rsyncArgs = buildRsyncArgs(config);
|
||||||
const { stdout } = await execAsync(command);
|
const { stdout } = await spawnAsync('rsync', rsyncArgs);
|
||||||
const stats = parseRsyncStats(stdout);
|
const stats = parseRsyncStats(stdout);
|
||||||
|
|
||||||
const backedUpFiles = files.map(file => file.relativePath);
|
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,
|
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
|
||||||
backedUpSize: totalSize,
|
backedUpSize: totalSize,
|
||||||
backedUpFiles,
|
backedUpFiles,
|
||||||
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`,
|
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
|
||||||
rsyncCommand: command
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { exec } = require('child_process');
|
|
||||||
const { promisify } = require('util');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
|
||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const { pipeline } = require('stream/promises');
|
const { pipeline } = require('stream/promises');
|
||||||
const { createReadStream, createWriteStream } = require('fs');
|
const { createReadStream, createWriteStream } = require('fs');
|
||||||
@@ -163,10 +161,10 @@ class DatabaseBackupService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use SQLite's backup API for consistency
|
// Use SQLite's backup API for consistency
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]);
|
||||||
|
|
||||||
// Verify the backup
|
// 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')) {
|
if (!verifyResult.stdout.includes('ok')) {
|
||||||
throw new Error('Backup integrity check failed');
|
throw new Error('Backup integrity check failed');
|
||||||
}
|
}
|
||||||
@@ -192,14 +190,6 @@ class DatabaseBackupService {
|
|||||||
async createPostgreSQLBackup(outputPath, options = {}) {
|
async createPostgreSQLBackup(outputPath, options = {}) {
|
||||||
const { host, port, user, password, database } = knexConfig.connection;
|
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
|
// Set PGPASSWORD environment variable for security
|
||||||
const env = { ...process.env };
|
const env = { ...process.env };
|
||||||
if (password) {
|
if (password) {
|
||||||
@@ -227,13 +217,16 @@ class DatabaseBackupService {
|
|||||||
pgDumpOptions.push('--compress=6');
|
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 {
|
try {
|
||||||
const { stderr } = await execAsync(command, {
|
const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env });
|
||||||
env,
|
|
||||||
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
|
|
||||||
});
|
|
||||||
|
|
||||||
// pg_dump writes progress to stderr, not an error
|
// pg_dump writes progress to stderr, not an error
|
||||||
if (stderr && !stderr.includes('dump complete')) {
|
if (stderr && !stderr.includes('dump complete')) {
|
||||||
@@ -261,7 +254,7 @@ class DatabaseBackupService {
|
|||||||
try {
|
try {
|
||||||
if (this.dbType === 'sqlite') {
|
if (this.dbType === 'sqlite') {
|
||||||
// For SQLite, we can directly check integrity
|
// 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')) {
|
if (!result.stdout.includes('ok')) {
|
||||||
throw new Error('Backup integrity check failed');
|
throw new Error('Backup integrity check failed');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) {
|
|||||||
failOnError: false // Don't fail on minor issues
|
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
|
// Apply resize with configured settings
|
||||||
// For square thumbnails with 'cover' fit, we crop to center
|
// For square thumbnails with 'cover' fit, we crop to center
|
||||||
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||||
@@ -339,6 +342,9 @@ async function generateHeroImage(imagePath, options = {}) {
|
|||||||
failOnError: false
|
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
|
// Resize to fit hero dimensions while maintaining aspect ratio
|
||||||
// Use 'cover' to fill the hero area (crops if needed)
|
// Use 'cover' to fill the hero area (crops if needed)
|
||||||
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
|
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ const crypto = require('crypto');
|
|||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const { pipeline } = require('stream/promises');
|
const { pipeline } = require('stream/promises');
|
||||||
const { createReadStream, createWriteStream } = require('fs');
|
const { createReadStream, createWriteStream } = require('fs');
|
||||||
const { exec } = require('child_process');
|
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
|
||||||
const { promisify } = require('util');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const knexConfig = require('../../knexfile');
|
const knexConfig = require('../../knexfile');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
@@ -418,12 +416,14 @@ class RestoreService {
|
|||||||
let availableBytes = 0;
|
let availableBytes = 0;
|
||||||
let diskCheckSucceeded = false;
|
let diskCheckSucceeded = false;
|
||||||
try {
|
try {
|
||||||
const { exec } = require('child_process');
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
// Use root path as fallback if storage path doesn't exist yet
|
// Use root path as fallback if storage path doesn't exist yet
|
||||||
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
|
||||||
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
|
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
|
||||||
const parsed = parseInt(stdout.trim());
|
// 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) {
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
availableBytes = parsed * 1024; // Convert from KB to bytes
|
availableBytes = parsed * 1024; // Convert from KB to bytes
|
||||||
diskCheckSucceeded = true;
|
diskCheckSucceeded = true;
|
||||||
@@ -492,15 +492,12 @@ class RestoreService {
|
|||||||
|
|
||||||
if (this.dbType === 'sqlite') {
|
if (this.dbType === 'sqlite') {
|
||||||
const dbPath = knexConfig.connection.filename;
|
const dbPath = knexConfig.connection.filename;
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
|
||||||
} else {
|
} else {
|
||||||
// PostgreSQL backup
|
// PostgreSQL backup
|
||||||
const { host, port, user, password, database } = knexConfig.connection;
|
const { host, port, user, password, database } = knexConfig.connection;
|
||||||
const env = { ...process.env, PGPASSWORD: password };
|
const env = { ...process.env, PGPASSWORD: password };
|
||||||
await execAsync(
|
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
|
||||||
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress database backup
|
// Compress database backup
|
||||||
@@ -514,7 +511,7 @@ class RestoreService {
|
|||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
|
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
|
// Create backup manifest
|
||||||
@@ -696,10 +693,10 @@ class RestoreService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Restore from backup
|
// Restore from backup
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
|
||||||
|
|
||||||
// Verify integrity
|
// 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')) {
|
if (!integrityCheck.stdout.includes('ok')) {
|
||||||
throw new Error('Database integrity check failed after restore');
|
throw new Error('Database integrity check failed after restore');
|
||||||
}
|
}
|
||||||
@@ -722,21 +719,12 @@ class RestoreService {
|
|||||||
// Drop and recreate database (extremely dangerous!)
|
// Drop and recreate database (extremely dangerous!)
|
||||||
this.log('warn', 'Dropping and recreating PostgreSQL database...');
|
this.log('warn', 'Dropping and recreating PostgreSQL database...');
|
||||||
|
|
||||||
await execAsync(
|
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
|
||||||
`psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
|
|
||||||
await execAsync(
|
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env });
|
||||||
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Restore from backup
|
// Restore from backup
|
||||||
await execAsync(
|
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
|
||||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
|
|
||||||
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-initialize database connection
|
// Re-initialize database connection
|
||||||
@@ -987,14 +975,11 @@ class RestoreService {
|
|||||||
|
|
||||||
if (this.dbType === 'sqlite') {
|
if (this.dbType === 'sqlite') {
|
||||||
const dbPath = knexConfig.connection.filename;
|
const dbPath = knexConfig.connection.filename;
|
||||||
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
|
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
|
||||||
} else {
|
} else {
|
||||||
const { host, port, user, password, database } = knexConfig.connection;
|
const { host, port, user, password, database } = knexConfig.connection;
|
||||||
const env = { ...process.env, PGPASSWORD: password };
|
const env = { ...process.env, PGPASSWORD: password };
|
||||||
await execAsync(
|
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
|
||||||
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
|
|
||||||
{ env }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.unlink(decompressedPath);
|
await fs.unlink(decompressedPath);
|
||||||
@@ -1004,7 +989,7 @@ class RestoreService {
|
|||||||
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
|
||||||
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
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');
|
this.log('info', 'Rollback completed successfully');
|
||||||
|
|||||||
@@ -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
@@ -70,7 +70,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- postgres-data:/var/lib/postgresql/data
|
- postgres-data:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-5432}:5432"
|
- "127.0.0.1:${DB_PORT:-5432}:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -89,7 +89,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
ports:
|
ports:
|
||||||
- "${REDIS_PORT:-6379}:6379"
|
- "127.0.0.1:${REDIS_PORT:-6379}:6379"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
test: ["CMD", "redis-cli", "--raw", "incr", "ping"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
server_tokens off;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
@@ -21,9 +22,9 @@ server {
|
|||||||
# Security headers
|
# Security headers
|
||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
|
||||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline' 'unsafe-eval'" 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
|
# Health check endpoint
|
||||||
location /health {
|
location /health {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const config: VitestUserConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
sourcemap: true,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
|
|||||||
Reference in New Issue
Block a user