CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
Resolves: - CSRF false positive checked (global POST protection) - Publish mutex to prevent git lock / double deploy - Basic Auth rate limit checked before credential evaluation - Memory leak in rate limiter (added GC interval) - XSS in Toast messages - XSS in data-path attribute - CI healthcheck port mismatch (3000 -> 8080) - Added security headers (X-Frame-Options, X-Content-Type-Options)
136 lines
4.9 KiB
JavaScript
136 lines
4.9 KiB
JavaScript
// 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,
|
|
};
|