security: Fix critical vulnerabilities and harden application

## 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
This commit is contained in:
Paul Nothaft
2026-01-03 10:12:01 +01:00
parent 0da45e699a
commit f3c2cee362
5 changed files with 218 additions and 73 deletions
+24 -48
View File
@@ -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"
+103 -7
View File
@@ -1,5 +1,15 @@
#!/usr/bin/env node
/**
* Script to set/reset admin password
*
* Usage:
* node set-admin-password.js <new-password>
* 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 <new-password>
node set-admin-password.js --env
Options:
<new-password> 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);
}
}
+79 -15
View File
@@ -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.' });
@@ -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<WelcomeMessageEditorProps> = ({
onChange(e.target.value);
};
// Convert newlines to <br> tags for preview
// Convert newlines to <br> 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 <br> tags
return sanitized
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
+2 -1
View File
@@ -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<string, string> = {
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!',