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:
Paul Nothaft
2026-03-22 12:40:01 +01:00
committed by GitHub
co-authored by Paul Nothaft
parent a63f1a8dd9
commit 23cd9cb680
27 changed files with 425 additions and 169 deletions
+38 -5
View File
@@ -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);