From f3c2cee362e0e5becc98cc26e2f3f54ca70bcded Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 3 Jan 2026 10:12:01 +0100 Subject: [PATCH] security: Fix critical vulnerabilities and harden application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Security Fixes ### CRITICAL: Command Injection (adminBackup.js) - Replaced exec() with spawn() using argument arrays - Added input sanitization for host, user, and ssh_key - Added regex validation for hostname/IP format - Added username format validation - Added SSH key file existence check - Prevents shell metacharacter injection attacks ### HIGH: Hardcoded Password (set-admin-password.js) - Removed hardcoded 'admin123' password - Now requires password as CLI argument or env variable - Added password strength validation (8+ chars, mixed case, numbers, special chars) - Added --help flag with usage instructions - Invalidates existing sessions on password change ### MEDIUM: XSS Vulnerability (WelcomeMessageEditor.tsx) - Added DOMPurify sanitization to getPreviewHtml() - Strips all HTML tags before rendering preview - Prevents script injection in admin preview ### LOW: Sample Password Exposure (EmailConfigPage.tsx) - Replaced plaintext sample password with masked placeholder - Uses '••••••••' instead of realistic password ## Dependency Updates - Fixed npm audit vulnerabilities (jws, qs, express) - Backend: 0 vulnerabilities - Frontend: 0 vulnerabilities --- backend/package-lock.json | 72 ++++-------- backend/scripts/set-admin-password.js | 110 ++++++++++++++++-- backend/src/routes/adminBackup.js | 94 ++++++++++++--- .../components/admin/WelcomeMessageEditor.tsx | 12 +- frontend/src/pages/admin/EmailConfigPage.tsx | 3 +- 5 files changed, 218 insertions(+), 73 deletions(-) diff --git a/backend/package-lock.json b/backend/package-lock.json index cd1b54f8..5b858263 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -4355,21 +4355,6 @@ "node": ">= 0.8" } }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/body-parser/node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -5766,39 +5751,39 @@ } }, "node_modules/express": { - "version": "4.21.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", - "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -5836,15 +5821,6 @@ "node": ">= 8.0.0" } }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -7837,12 +7813,12 @@ } }, "node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -9694,12 +9670,12 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" diff --git a/backend/scripts/set-admin-password.js b/backend/scripts/set-admin-password.js index 2c994a3b..107a41b6 100644 --- a/backend/scripts/set-admin-password.js +++ b/backend/scripts/set-admin-password.js @@ -1,5 +1,15 @@ #!/usr/bin/env node +/** + * Script to set/reset admin password + * + * Usage: + * node set-admin-password.js + * node set-admin-password.js --env (uses ADMIN_PASSWORD environment variable) + * + * Security: Password must be at least 8 characters with mixed case, numbers, and special characters + */ + const bcrypt = require('bcrypt'); const path = require('path'); require('dotenv').config({ path: path.join(__dirname, '../.env') }); @@ -16,22 +26,108 @@ const db = knex({ } }); +/** + * Validate password strength + */ +function validatePassword(password) { + if (!password || password.length < 8) { + return { valid: false, error: 'Password must be at least 8 characters long' }; + } + if (!/[a-z]/.test(password)) { + return { valid: false, error: 'Password must contain at least one lowercase letter' }; + } + if (!/[A-Z]/.test(password)) { + return { valid: false, error: 'Password must contain at least one uppercase letter' }; + } + if (!/[0-9]/.test(password)) { + return { valid: false, error: 'Password must contain at least one number' }; + } + if (!/[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)) { + return { valid: false, error: 'Password must contain at least one special character' }; + } + return { valid: true }; +} + +function printUsage() { + console.log(` +Usage: + node set-admin-password.js + node set-admin-password.js --env + +Options: + The new password to set (must meet security requirements) + --env Use ADMIN_PASSWORD environment variable + +Security Requirements: + - At least 8 characters + - At least one lowercase letter + - At least one uppercase letter + - At least one number + - At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?) + +Examples: + node set-admin-password.js "MySecure@Pass123" + ADMIN_PASSWORD="MySecure@Pass123" node set-admin-password.js --env +`); +} + async function setAdminPassword() { try { - const password = 'admin123'; - const hashedPassword = await bcrypt.hash(password, 10); - - await db('admin_users') + // Get password from argument or environment + const args = process.argv.slice(2); + let password; + + if (args.length === 0) { + console.error('❌ Error: No password provided\n'); + printUsage(); + process.exit(1); + } + + if (args[0] === '--env') { + password = process.env.ADMIN_PASSWORD; + if (!password) { + console.error('❌ Error: ADMIN_PASSWORD environment variable not set'); + process.exit(1); + } + } else if (args[0] === '--help' || args[0] === '-h') { + printUsage(); + process.exit(0); + } else { + password = args[0]; + } + + // Validate password strength + const validation = validatePassword(password); + if (!validation.valid) { + console.error(`❌ Error: ${validation.error}`); + process.exit(1); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 12); + + // Update database + const updated = await db('admin_users') .where('username', 'admin') .update({ password_hash: hashedPassword, + password_changed_at: new Date(), updated_at: new Date() }); - - console.log('✅ Admin password set to: admin123'); + + if (updated === 0) { + console.error('❌ Error: Admin user not found'); + process.exit(1); + } + + console.log('✅ Admin password updated successfully'); + console.log(' Note: All existing sessions have been invalidated'); + + await db.destroy(); process.exit(0); } catch (error) { - console.error('❌ Error setting password:', error); + console.error('❌ Error setting password:', error.message); + await db.destroy(); process.exit(1); } } diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js index 203c6e6f..84dba64a 100644 --- a/backend/src/routes/adminBackup.js +++ b/backend/src/routes/adminBackup.js @@ -230,25 +230,89 @@ router.post('/test-connection', adminAuth, async (req, res) => { break; case 'rsync': - // Test rsync connection - const { exec } = require('child_process'); - const { promisify } = require('util'); - const execAsync = promisify(exec); - - const sshCommand = config.ssh_key - ? `ssh -i ${config.ssh_key} -o StrictHostKeyChecking=no -o ConnectTimeout=10` - : 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10'; - - const testCommand = config.user - ? `${sshCommand} ${config.user}@${config.host} "echo 'Connection successful'"` - : `${sshCommand} ${config.host} "echo 'Connection successful'"`; - + // Test rsync connection using spawn with argument arrays to prevent command injection + const { spawn } = require('child_process'); + + // Validate and sanitize inputs to prevent command injection + const sanitizeInput = (input) => { + if (!input || typeof input !== 'string') return null; + // Remove any shell metacharacters and limit length + return input.replace(/[;&|`$(){}[\]<>\\!#*?"'\n\r]/g, '').substring(0, 255); + }; + + const host = sanitizeInput(config.host); + const user = sanitizeInput(config.user); + const sshKeyPath = sanitizeInput(config.ssh_key); + + if (!host) { + res.json({ success: false, message: 'Invalid host specified' }); + break; + } + + // 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)) { + res.json({ success: false, message: 'Invalid host format' }); + 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' }); + break; + } + + // Build SSH arguments as array (safe from injection) + const sshArgs = []; + if (sshKeyPath) { + // Validate SSH key path exists and is a file + const fsSync = require('fs'); + if (!fsSync.existsSync(sshKeyPath) || !fsSync.statSync(sshKeyPath).isFile()) { + res.json({ success: false, message: 'SSH key file not found' }); + break; + } + sshArgs.push('-i', sshKeyPath); + } + sshArgs.push('-o', 'StrictHostKeyChecking=no'); + sshArgs.push('-o', 'ConnectTimeout=10'); + sshArgs.push('-o', 'BatchMode=yes'); + + // Add target (user@host or just host) + const target = user ? `${user}@${host}` : host; + sshArgs.push(target); + sshArgs.push('echo', 'Connection successful'); + try { - const { stdout } = await execAsync(testCommand); + const result = await new Promise((resolve, reject) => { + const sshProcess = spawn('ssh', sshArgs, { + timeout: 15000, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + sshProcess.stdout.on('data', (data) => { stdout += data; }); + sshProcess.stderr.on('data', (data) => { stderr += data; }); + + sshProcess.on('close', (code) => { + if (code === 0) { + resolve({ success: true, stdout }); + } else { + reject(new Error(stderr || `SSH exited with code ${code}`)); + } + }); + + sshProcess.on('error', (err) => { + reject(err); + }); + }); + res.json({ success: true, message: 'Rsync connection successful' }); } catch (error) { logger.warn('Rsync connection test failed', { - destination: config.host || config.destination, + destination: host, error: error.message }); res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' }); diff --git a/frontend/src/components/admin/WelcomeMessageEditor.tsx b/frontend/src/components/admin/WelcomeMessageEditor.tsx index d41113c3..9d96284e 100644 --- a/frontend/src/components/admin/WelcomeMessageEditor.tsx +++ b/frontend/src/components/admin/WelcomeMessageEditor.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { HelpCircle } from 'lucide-react'; +import DOMPurify from 'dompurify'; interface WelcomeMessageEditorProps { value: string; @@ -18,9 +19,16 @@ export const WelcomeMessageEditor: React.FC = ({ onChange(e.target.value); }; - // Convert newlines to
tags for preview + // Convert newlines to
tags for preview with XSS sanitization const getPreviewHtml = () => { - return value + // First sanitize the input to remove any malicious content + const sanitized = DOMPurify.sanitize(value, { + ALLOWED_TAGS: [], // Strip all HTML tags, only allow text + ALLOWED_ATTR: [], + KEEP_CONTENT: true + }); + // Then convert newlines to
tags + return sanitized .split('\n') .map(line => line.trim()) .filter(line => line.length > 0) diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index f080e4c7..be16c32d 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -227,10 +227,11 @@ export const EmailConfigPage: React.FC = () => { if (!selectedTemplateKey || !editedTemplate) return; // Generate sample data based on the template + // Note: These are clearly marked placeholder values for template preview only const sampleData: Record = { event_name: 'John & Jane Wedding', event_date: 'December 25, 2024', - password: 'wedding2024', + password: '••••••••', // Masked placeholder for preview gallery_link: 'https://photos.example.com/gallery/john-jane-wedding', expiration_date: 'January 25, 2025', welcome_message: 'Thank you for celebrating our special day with us!',