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]>
173 lines
4.5 KiB
JavaScript
173 lines
4.5 KiB
JavaScript
/**
|
|
* CSS Sanitizer
|
|
* Sanitizes user-provided CSS to prevent security vulnerabilities
|
|
*/
|
|
|
|
// Patterns that should be blocked for security
|
|
const FORBIDDEN_PATTERNS = [
|
|
// JavaScript execution
|
|
/expression\s*\(/gi,
|
|
/javascript:/gi,
|
|
/behavior\s*:/gi,
|
|
/-moz-binding/gi,
|
|
/vbscript:/gi,
|
|
|
|
// External resources (potential data exfiltration)
|
|
/@import/gi,
|
|
|
|
// Dangerous at-rules
|
|
/@charset/gi,
|
|
/@namespace/gi,
|
|
|
|
// IE-specific exploits
|
|
/\\0/g, // Null byte
|
|
/\\9/g, // IE CSS hack
|
|
|
|
// Script injection attempts
|
|
/<script/gi,
|
|
/<\/script/gi,
|
|
/on\w+\s*=/gi, // onclick=, onload=, etc.
|
|
];
|
|
|
|
// Pattern for external URLs (block external, allow only safe raster data: images)
|
|
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image\/(?:jpeg|jpg|png|gif|webp))/gi;
|
|
|
|
// Maximum CSS size in bytes (100KB)
|
|
const MAX_CSS_SIZE = 100 * 1024;
|
|
|
|
/**
|
|
* Basic CSS sanitization (original function, kept for compatibility)
|
|
*/
|
|
function sanitizeCss(css) {
|
|
if (!css || typeof css !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
let sanitized = css;
|
|
|
|
const disallowedPatterns = [
|
|
/@import[^;]+;?/gi,
|
|
/@charset[^;]+;?/gi,
|
|
/expression\s*\([^)]*\)/gi,
|
|
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
|
|
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi,
|
|
/url\s*\(\s*(['"]?)\s*data:image\/svg\+xml[^)]*\)/gi
|
|
];
|
|
|
|
disallowedPatterns.forEach((pattern) => {
|
|
sanitized = sanitized.replace(pattern, '');
|
|
});
|
|
|
|
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
|
|
|
const MAX_LENGTH = 100 * 1024;
|
|
if (sanitized.length > MAX_LENGTH) {
|
|
sanitized = sanitized.slice(0, MAX_LENGTH);
|
|
}
|
|
|
|
return sanitized.trim();
|
|
}
|
|
|
|
/**
|
|
* Enhanced CSS sanitization with warnings
|
|
* @param {string} cssContent - Raw CSS content
|
|
* @returns {Object} - { sanitized: string, warnings: string[] }
|
|
*/
|
|
function sanitizeCSS(cssContent) {
|
|
if (!cssContent || typeof cssContent !== 'string') {
|
|
return { sanitized: '', warnings: [] };
|
|
}
|
|
|
|
const warnings = [];
|
|
let sanitized = cssContent;
|
|
|
|
// Check size
|
|
if (sanitized.length > MAX_CSS_SIZE) {
|
|
warnings.push(`CSS exceeds maximum size of ${MAX_CSS_SIZE / 1024}KB`);
|
|
sanitized = sanitized.substring(0, MAX_CSS_SIZE);
|
|
}
|
|
|
|
// Remove forbidden patterns
|
|
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
const patternStr = pattern.toString();
|
|
// Reset lastIndex for global patterns
|
|
pattern.lastIndex = 0;
|
|
if (pattern.test(sanitized)) {
|
|
const patternName = patternStr.replace(/\/[gi]*/g, '').substring(0, 30);
|
|
warnings.push(`Blocked potentially unsafe pattern: ${patternName}`);
|
|
pattern.lastIndex = 0;
|
|
sanitized = sanitized.replace(pattern, '/* BLOCKED */');
|
|
}
|
|
}
|
|
|
|
// Block external URLs (only allow data: URIs for images)
|
|
EXTERNAL_URL_PATTERN.lastIndex = 0;
|
|
if (EXTERNAL_URL_PATTERN.test(sanitized)) {
|
|
warnings.push('Blocked external URL references. Only data: URIs are allowed for images.');
|
|
EXTERNAL_URL_PATTERN.lastIndex = 0;
|
|
sanitized = sanitized.replace(EXTERNAL_URL_PATTERN, '/* BLOCKED URL */ url(');
|
|
}
|
|
|
|
// Remove HTML comments that might be used for injection
|
|
sanitized = sanitized.replace(/<!--[\s\S]*?-->/g, '');
|
|
|
|
// Remove control characters
|
|
sanitized = sanitized.replace(/[\u0000-\u001F\u007F]/g, '');
|
|
|
|
// Remove any remaining script-like content
|
|
sanitized = sanitized.replace(/<[^>]*>/g, '/* BLOCKED TAG */');
|
|
|
|
return { sanitized: sanitized.trim(), warnings };
|
|
}
|
|
|
|
/**
|
|
* Validate CSS syntax (basic check)
|
|
* @param {string} cssContent - CSS content to validate
|
|
* @returns {Object} - { valid: boolean, error?: string }
|
|
*/
|
|
function validateCSS(cssContent) {
|
|
if (!cssContent || cssContent.trim() === '') {
|
|
return { valid: true };
|
|
}
|
|
|
|
// Basic bracket matching
|
|
const openBraces = (cssContent.match(/{/g) || []).length;
|
|
const closeBraces = (cssContent.match(/}/g) || []).length;
|
|
|
|
if (openBraces !== closeBraces) {
|
|
return {
|
|
valid: false,
|
|
error: `Mismatched braces: ${openBraces} opening, ${closeBraces} closing`
|
|
};
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
/**
|
|
* Scope CSS to gallery page
|
|
* @param {string} cssContent - CSS content
|
|
* @returns {string} - Scoped CSS
|
|
*/
|
|
function scopeToGalleryPage(cssContent) {
|
|
if (!cssContent || cssContent.trim() === '') {
|
|
return '';
|
|
}
|
|
|
|
// If the CSS already uses .gallery-page, return as-is
|
|
if (cssContent.includes('.gallery-page')) {
|
|
return cssContent;
|
|
}
|
|
|
|
// Simple scoping: wrap entire content in .gallery-page
|
|
return `.gallery-page {\n${cssContent}\n}`;
|
|
}
|
|
|
|
module.exports = {
|
|
sanitizeCss,
|
|
sanitizeCSS,
|
|
validateCSS,
|
|
scopeToGalleryPage,
|
|
MAX_CSS_SIZE
|
|
};
|