23cd9cb680
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 <paul@MacStudio-von-Paul.local>
129 lines
3.2 KiB
JavaScript
129 lines
3.2 KiB
JavaScript
const ADMIN_COOKIE_NAME = 'admin_token';
|
|
const GALLERY_COOKIE_NAME = 'gallery_token';
|
|
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
|
|
|
|
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
const secureCookie = (() => {
|
|
if (typeof process.env.COOKIE_SECURE === 'string') {
|
|
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
|
|
}
|
|
// Default to true in production (HTTPS expected), false in development
|
|
return process.env.NODE_ENV === 'production';
|
|
})();
|
|
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
|
|
const cookieDomain = process.env.COOKIE_DOMAIN;
|
|
|
|
function buildCookieBaseOptions() {
|
|
const options = {
|
|
httpOnly: true,
|
|
secure: secureCookie,
|
|
sameSite: sameSiteDefault,
|
|
path: '/',
|
|
};
|
|
|
|
if (cookieDomain) {
|
|
options.domain = cookieDomain;
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) {
|
|
return {
|
|
...buildCookieBaseOptions(),
|
|
maxAge: maxAgeMs,
|
|
};
|
|
}
|
|
|
|
function sanitizeSlugForCookie(slug = '') {
|
|
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
|
|
}
|
|
|
|
function setAdminAuthCookie(res, token) {
|
|
if (!token) return;
|
|
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry());
|
|
}
|
|
|
|
function clearAdminAuthCookie(res) {
|
|
res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions());
|
|
}
|
|
|
|
function setGalleryAuthCookies(res, token, slug) {
|
|
if (!token) return;
|
|
const options = buildCookieOptionsWithExpiry();
|
|
res.cookie(GALLERY_COOKIE_NAME, token, options);
|
|
if (slug) {
|
|
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
|
res.cookie(cookieName, token, options);
|
|
}
|
|
}
|
|
|
|
function clearGalleryAuthCookies(res, slug) {
|
|
const baseOptions = buildCookieBaseOptions();
|
|
res.clearCookie(GALLERY_COOKIE_NAME, baseOptions);
|
|
|
|
const cookies = res.req?.cookies || {};
|
|
|
|
if (slug) {
|
|
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
|
res.clearCookie(cookieName, baseOptions);
|
|
} else {
|
|
Object.keys(cookies).forEach((name) => {
|
|
if (name.startsWith(GALLERY_COOKIE_PREFIX)) {
|
|
res.clearCookie(name, baseOptions);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function getAdminTokenFromRequest(req) {
|
|
const header = req.headers?.authorization;
|
|
if (header && header.startsWith('Bearer ')) {
|
|
return header.substring(7);
|
|
}
|
|
return req.cookies?.[ADMIN_COOKIE_NAME] || null;
|
|
}
|
|
|
|
function getGalleryTokenFromRequest(req, slug) {
|
|
const header = req.headers?.authorization;
|
|
if (header && header.startsWith('Bearer ')) {
|
|
return header.substring(7);
|
|
}
|
|
|
|
if (!req.cookies) {
|
|
return null;
|
|
}
|
|
|
|
if (slug) {
|
|
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
|
|
if (req.cookies[cookieName]) {
|
|
return req.cookies[cookieName];
|
|
}
|
|
}
|
|
|
|
if (req.cookies[GALLERY_COOKIE_NAME]) {
|
|
return req.cookies[GALLERY_COOKIE_NAME];
|
|
}
|
|
|
|
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GALLERY_COOKIE_PREFIX));
|
|
if (prefixed) {
|
|
return req.cookies[prefixed];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
module.exports = {
|
|
ADMIN_COOKIE_NAME,
|
|
GALLERY_COOKIE_NAME,
|
|
GALLERY_COOKIE_PREFIX,
|
|
sanitizeSlugForCookie,
|
|
setAdminAuthCookie,
|
|
clearAdminAuthCookie,
|
|
setGalleryAuthCookies,
|
|
clearGalleryAuthCookies,
|
|
getAdminTokenFromRequest,
|
|
getGalleryTokenFromRequest,
|
|
};
|