feat(cms): version history panel with diff view and one-click restore
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
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
- GET /versions lists the automatic backups of the selected file (timestamp,
size); ?show=<backup> renders a line diff against the current content
- POST /restore validates the backup against the content schema and restores
it atomically; the pre-restore state gets a fresh backup first, so a
restore itself is reversible; audited as version_restored
- dependency-free LCS line diff (scripts/cms-diff.js) with add/del
highlighting and context trimming; backup names validated against a strict
pattern (path traversal impossible)
- new 🕘 Verziók entry in the CMS bottom bar
- refactor: security/infra helpers extracted to scripts/cms-core.js to keep
content-editor.js under the 400-line hard limit
- integration test: list, diff, restore + reversibility backup, traversal
rejection, CSRF enforcement, auth
Closes MITHOME-64
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
// 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 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 [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':');
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
CMS_USER,
|
||||
CMS_PASS,
|
||||
CMS_DEPLOY_ENV,
|
||||
CSRF_TOKEN,
|
||||
securityConfigIsValid,
|
||||
getClientAddress,
|
||||
exceedsRateLimit,
|
||||
hasValidCredentials,
|
||||
isBrowserNavigation,
|
||||
makeIsAuthenticated,
|
||||
hasValidCsrfToken,
|
||||
makeWriteAudit,
|
||||
backupAndWriteAtomically,
|
||||
};
|
||||
Reference in New Issue
Block a user