// Security and infrastructure helpers for the Content Editor, extracted so // content-editor.js stays focused on HTTP routing (file-size limits). // Dependencies (validateLogin, hasValidSession) are injected to avoid cycles. const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const CMS_USER = process.env.CMS_USER; const CMS_PASS = process.env.CMS_PASS; const CMS_DEPLOY_ENV = process.env.CMS_DEPLOY_ENV; const CSRF_TOKEN = process.env.CMS_CSRF_TOKEN || crypto.randomBytes(32).toString('hex'); const rateLimits = new Map(); function securityConfigIsValid() { return Boolean(CMS_USER && CMS_PASS && ['staging', 'production'].includes(CMS_DEPLOY_ENV)); } function getClientAddress(req) { // The editor only listens on 127.0.0.1; the staging Nginx proxy supplies this header. // WHY: take the LAST entry. Nginx ($proxy_add_x_forwarded_for) appends the real client // IP to the list, so the first entry may be a spoofed value sent by the client — using // it would let attackers bypass the rate limiter with a fresh "IP" per request. const forwarded = req.headers['x-forwarded-for']; if (typeof forwarded === 'string' && forwarded.trim()) { const parts = forwarded.split(',').map(part => part.trim()).filter(Boolean); if (parts.length > 0) return parts[parts.length - 1]; } return req.socket.remoteAddress || 'unknown'; } function isRateLimited(key, limit, windowMs) { const now = Date.now(); const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs); if (attempts.length === 0) { rateLimits.delete(key); return false; } rateLimits.set(key, attempts); return attempts.length >= limit; } function recordRateLimitAttempt(key, windowMs) { const now = Date.now(); const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs); attempts.push(now); rateLimits.set(key, attempts); } function exceedsRateLimit(key, limit, windowMs) { const now = Date.now(); const attempts = (rateLimits.get(key) || []).filter(time => now - time < windowMs); attempts.push(now); rateLimits.set(key, attempts); return attempts.length > limit; } function hasValidCredentials(req, validateLogin) { const b64auth = (req.headers.authorization || '').split(' ')[1] || ''; const str = Buffer.from(b64auth, 'base64').toString(); const colonIdx = str.indexOf(':'); const login = colonIdx !== -1 ? str.slice(0, colonIdx) : str; const password = colonIdx !== -1 ? str.slice(colonIdx + 1) : ''; return validateLogin(login, password, CMS_USER, CMS_PASS); } function isBrowserNavigation(req) { return req.method === 'GET' && String(req.headers.accept || '').includes('text/html'); } // WHY: Safari (and other browsers) cache Basic Auth credentials and resend them // automatically, which would let an already-logged-out browser straight back in. // Browser navigations therefore authenticate ONLY via the session cookie, so // logout is final. Non-browser requests (curl, API clients) keep Basic Auth. function makeIsAuthenticated(hasValidSession, validateLogin) { return function isAuthenticated(req) { if (isBrowserNavigation(req)) return hasValidSession(req); return hasValidCredentials(req, validateLogin) || hasValidSession(req); }; } function hasValidCsrfToken(req) { const token = req.headers['x-csrf-token']; return typeof token === 'string' && token.length === CSRF_TOKEN.length && crypto.timingSafeEqual(Buffer.from(token), Buffer.from(CSRF_TOKEN)); } function makeWriteAudit(auditFile) { return function writeAudit(event, details = {}) { const record = { timestamp: new Date().toISOString(), event, ...details }; fs.appendFileSync(auditFile, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 }); }; } function backupAndWriteAtomically(targetFile, data, backupDir) { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const backupName = `${path.basename(targetFile, '.json')}.${timestamp}.json`; const backupFile = path.join(backupDir, backupName); const tempFile = `${targetFile}.${process.pid}.${Date.now()}.tmp`; fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 }); fs.copyFileSync(targetFile, backupFile); fs.writeFileSync(tempFile, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); fs.renameSync(tempFile, targetFile); return backupFile; } const RATE_LIMIT_GC_INTERVAL_MS = 5 * 60 * 1000; setInterval(() => { const now = Date.now(); for (const [key, attempts] of rateLimits) { const valid = attempts.filter(t => now - t < 15 * 60 * 1000); if (valid.length === 0) rateLimits.delete(key); else rateLimits.set(key, valid); } }, RATE_LIMIT_GC_INTERVAL_MS).unref(); module.exports = { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, isRateLimited, recordRateLimitAttempt, exceedsRateLimit, hasValidCredentials, isBrowserNavigation, makeIsAuthenticated, hasValidCsrfToken, makeWriteAudit, backupAndWriteAtomically, };