fix: address Shannon security assessment findings (37 vulnerabilities) (#254)
Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities identified in the Shannon security assessment (2026-03-20). Critical fixes: - Command injection via rsync SSH key path (INJ-VULN-01) - Self-escalation to super_admin role (AUTHZ-VULN-11) - Invite super_admin backdoor (AUTHZ-VULN-12) - Handlebars SSTI in email templates (INJ-VULN-05) Authentication hardening: - Rate limit on share-link login (AUTH-VULN-01) - X-Forwarded-For spoofing bypass (AUTH-VULN-02) - reCAPTCHA fails closed when misconfigured (AUTH-VULN-03) - Token revocation on admin/gallery logout (AUTH-VULN-04/05) - Cookie Secure flag defaults true in production (AUTH-VULN-06) - Remove JWT from admin login response body (AUTH-VULN-07) - Timing-safe gallery slug validation (AUTH-VULN-09) - Account lockout fails closed on DB error (AUTH-VULN-12) - Session endpoint checks token revocation Path traversal & file access: - checksums endpoint path containment (INJ-VULN-03) - manifest validate path containment (INJ-VULN-04) XSS prevention: - Block SVG data URIs in CSS sanitizer (XSS-VULN-01) - Email preview iframe sandbox (XSS-VULN-02) - SSR branding HTML escaping (XSS-VULN-03) - User-Agent sanitization in feedback (XSS-VULN-04) Authorization (IDOR): - Event ownership middleware for all admin routes - Cross-admin user profile read restriction (AUTHZ-VULN-10) SSRF & infrastructure: - Private IP validation for SMTP, S3, rsync hosts - Replace inline JWT with standard adminAuth middleware - CSRF Content-Type enforcement on mutating API endpoints - CSP headers in nginx location blocks Token revocation fix: - Remove overly broad orWhere clause that invalidated all future tokens - Allow empty-body POST requests (logout) in CSRF middleware Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
a63f1a8dd9
commit
23cd9cb680
@@ -437,26 +437,59 @@ async function performLocalBackup(config, files) {
|
||||
};
|
||||
}
|
||||
|
||||
function validateRsyncParam(value, label) {
|
||||
if (!value || typeof value !== 'string') return null;
|
||||
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
|
||||
throw new Error(`Invalid ${label}: contains disallowed characters`);
|
||||
}
|
||||
if (value.length > 1024) {
|
||||
throw new Error(`Invalid ${label}: too long`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildRsyncArgs(config) {
|
||||
const storagePath = getStoragePath();
|
||||
const host = config.backup_rsync_host;
|
||||
const remotePath = config.backup_rsync_path;
|
||||
const host = validateRsyncParam(config.backup_rsync_host, 'host');
|
||||
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
|
||||
|
||||
if (!host || !remotePath) {
|
||||
throw new Error('Rsync configuration incomplete');
|
||||
}
|
||||
|
||||
// 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)) {
|
||||
throw new Error('Invalid rsync host format');
|
||||
}
|
||||
|
||||
const args = ['-avz', '--delete', '--stats'];
|
||||
if (config.backup_rsync_ssh_key) {
|
||||
args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`);
|
||||
const sshKey = validateRsyncParam(config.backup_rsync_ssh_key, 'SSH key path');
|
||||
const fs = require('fs');
|
||||
if (!fs.existsSync(sshKey) || !fs.statSync(sshKey).isFile()) {
|
||||
throw new Error('SSH key file not found or is not a file');
|
||||
}
|
||||
// Pass SSH options as separate array elements to avoid shell interpretation
|
||||
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
|
||||
}
|
||||
|
||||
const excludePatterns = config.backup_exclude_patterns || [];
|
||||
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
|
||||
|
||||
const source = `${storagePath}/`;
|
||||
const destination = config.backup_rsync_user
|
||||
? `${config.backup_rsync_user}@${host}:${remotePath}`
|
||||
|
||||
const user = config.backup_rsync_user;
|
||||
if (user) {
|
||||
validateRsyncParam(user, 'user');
|
||||
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
|
||||
throw new Error('Invalid rsync username format');
|
||||
}
|
||||
}
|
||||
|
||||
const destination = user
|
||||
? `${user}@${host}:${remotePath}`
|
||||
: `${host}:${remotePath}`;
|
||||
|
||||
args.push(source, destination);
|
||||
|
||||
@@ -345,15 +345,16 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
|
||||
}
|
||||
|
||||
// Compile templates with Handlebars
|
||||
const subjectTemplate = Handlebars.compile(subject);
|
||||
const htmlTemplate = Handlebars.compile(htmlBody);
|
||||
const textTemplate = Handlebars.compile(textBody);
|
||||
// Safe template replacement (no code execution, only simple variable substitution)
|
||||
function safeTemplateReplace(template, variables) {
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
|
||||
variables.hasOwnProperty(key) ? String(variables[key]) : match
|
||||
);
|
||||
}
|
||||
|
||||
// Process templates with processedVariables (includes formatted dates and security messages)
|
||||
subject = subjectTemplate(processedVariables);
|
||||
htmlBody = htmlTemplate(processedVariables);
|
||||
textBody = textTemplate(processedVariables);
|
||||
subject = safeTemplateReplace(subject, processedVariables);
|
||||
htmlBody = safeTemplateReplace(htmlBody, processedVariables);
|
||||
textBody = safeTemplateReplace(textBody, processedVariables);
|
||||
|
||||
// Inject client access section if client_link is provided (#172)
|
||||
if (processedVariables.client_link) {
|
||||
|
||||
@@ -163,22 +163,13 @@ async function createRateLimiter() {
|
||||
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
|
||||
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
|
||||
},
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
keyGenerator: (req) => req.ip,
|
||||
skip: async (req) => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return shouldSkipRateLimit(req, currentConfig);
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Enhanced logging for production analysis
|
||||
logger.warn('Rate limit exceeded', {
|
||||
@@ -223,22 +214,13 @@ async function createAuthRateLimiter() {
|
||||
return rateLimit({
|
||||
windowMs: config.windowMinutes * 60 * 1000,
|
||||
max: config.authMaxRequests,
|
||||
keyGenerator: (req) => {
|
||||
// Use correct client IP when behind proxy
|
||||
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
},
|
||||
keyGenerator: (req) => req.ip,
|
||||
skip: async () => {
|
||||
const currentConfig = await getRateLimitSettings();
|
||||
return !currentConfig.enabled;
|
||||
},
|
||||
handler: (req, res) => {
|
||||
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.headers['x-real-ip'] ||
|
||||
req.connection.remoteAddress ||
|
||||
req.ip;
|
||||
const clientIp = req.ip;
|
||||
|
||||
// Enhanced logging for auth failures
|
||||
logger.warn('Auth rate limit exceeded', {
|
||||
|
||||
@@ -30,10 +30,10 @@ async function verifyRecaptcha(token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no secret key configured, log warning but pass
|
||||
// If no secret key configured, fail closed
|
||||
if (!secretKey) {
|
||||
console.warn('reCAPTCHA enabled but no secret key configured');
|
||||
return true;
|
||||
console.warn('reCAPTCHA enabled but no secret key configured — blocking request');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -85,6 +85,16 @@ class S3StorageAdapter extends stream.EventEmitter {
|
||||
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
|
||||
}
|
||||
|
||||
// SSRF protection: block private/internal S3 endpoints in production
|
||||
// Local endpoints (e.g. MinIO on localhost) are allowed in development
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const { validateExternalUrl } = require('../../utils/networkValidation');
|
||||
const urlCheck = validateExternalUrl(endpoint);
|
||||
if (!urlCheck.valid) {
|
||||
throw new Error(`Invalid S3 endpoint: ${urlCheck.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
s3Config.endpoint = endpoint;
|
||||
|
||||
// For S3-compatible services with custom endpoints, force path style
|
||||
|
||||
@@ -18,7 +18,7 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
|
||||
* @param {object} params - { email, roleId, invitedById }
|
||||
* @returns {Promise<object>} Created invitation details
|
||||
*/
|
||||
async function createInvitation({ email, roleId, invitedById }) {
|
||||
async function createInvitation({ email, roleId, invitedById, inviterRoleName }) {
|
||||
// Check if email already exists
|
||||
const existingUser = await db('admin_users').where('email', email).first();
|
||||
if (existingUser) {
|
||||
@@ -42,6 +42,11 @@ async function createInvitation({ email, roleId, invitedById }) {
|
||||
throw new NotFoundError('Role', roleId);
|
||||
}
|
||||
|
||||
// Role hierarchy: only super_admin can invite super_admin
|
||||
if (role.name === 'super_admin' && inviterRoleName !== 'super_admin') {
|
||||
throw new ValidationError('Only Super Admins can invite new Super Admins');
|
||||
}
|
||||
|
||||
// Generate secure invitation token (64 characters hex = 32 bytes)
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
|
||||
@@ -213,7 +218,7 @@ async function getAdminUserById(id) {
|
||||
* @param {number} updatedById - ID of user making the update
|
||||
* @returns {Promise<object>} Updated user
|
||||
*/
|
||||
async function updateAdminUser(id, updates, updatedById) {
|
||||
async function updateAdminUser(id, updates, updatedById, requestingAdmin = {}) {
|
||||
const user = await db('admin_users').where('id', id).first();
|
||||
if (!user) {
|
||||
throw new NotFoundError('Admin user', id);
|
||||
@@ -248,6 +253,34 @@ async function updateAdminUser(id, updates, updatedById) {
|
||||
if (!role) {
|
||||
throw new NotFoundError('Role', updates.role_id);
|
||||
}
|
||||
|
||||
// Role hierarchy enforcement
|
||||
const superAdminRole = await db('roles').where('name', 'super_admin').first();
|
||||
const isSuperAdmin = requestingAdmin.roleName === 'super_admin';
|
||||
|
||||
// Only super_admin can assign super_admin role
|
||||
if (superAdminRole && role.id === superAdminRole.id && !isSuperAdmin) {
|
||||
throw new ValidationError('Only Super Admins can assign the Super Admin role');
|
||||
}
|
||||
|
||||
// Prevent self-role-update
|
||||
if (id === updatedById) {
|
||||
throw new ValidationError('Cannot change your own role');
|
||||
}
|
||||
|
||||
// Prevent downgrading the last super_admin
|
||||
if (superAdminRole && user.role_id === superAdminRole.id && role.id !== superAdminRole.id) {
|
||||
const superAdminCount = await db('admin_users')
|
||||
.where('role_id', superAdminRole.id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (Number(superAdminCount?.count) <= 1) {
|
||||
throw new ValidationError('Cannot demote the last Super Admin');
|
||||
}
|
||||
}
|
||||
|
||||
allowedUpdates.role_id = updates.role_id;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user