fix(cms): implement v2 security and stability review findings
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)
This commit is contained in:
Do Siki
2026-08-20 11:35:01 +02:00
parent 768297031d
commit c5d5198fbf
12 changed files with 132 additions and 61 deletions
+35 -1
View File
@@ -28,6 +28,24 @@ function getClientAddress(req) {
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);
@@ -38,7 +56,10 @@ function exceedsRateLimit(key, limit, windowMs) {
function hasValidCredentials(req, validateLogin) {
const b64auth = (req.headers.authorization || '').split(' ')[1] || '';
const [login = '', password = ''] = Buffer.from(b64auth, 'base64').toString().split(':');
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);
}
@@ -84,6 +105,17 @@ function backupAndWriteAtomically(targetFile, data, backupDir) {
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,
@@ -91,6 +123,8 @@ module.exports = {
CSRF_TOKEN,
securityConfigIsValid,
getClientAddress,
isRateLimited,
recordRateLimitAttempt,
exceedsRateLimit,
hasValidCredentials,
isBrowserNavigation,
+43 -30
View File
@@ -29,13 +29,13 @@ function renderPrimitive(path, val, container) {
const type = val === null ? 'null' : typeof val;
let control;
if (type === 'boolean') {
control = `<input type="checkbox" data-path="${path}" data-type="boolean" ${val ? 'checked' : ''}>`;
control = `<input type="checkbox" data-path="${esc(path)}" data-type="boolean" ${val ? 'checked' : ''}>`;
} else if (type === 'number') {
control = `<input type="number" data-path="${path}" data-type="number" value="${esc(val)}">`;
control = `<input type="number" data-path="${esc(path)}" data-type="number" value="${esc(val)}">`;
} else {
control = isLong
? `<textarea data-path="${path}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${path}" data-type="${type}" value="${esc(val ?? '')}">`;
? `<textarea data-path="${esc(path)}" data-type="${type}" rows="${Math.min(8,Math.max(2,Math.ceil(String(val).length/80)))}">${esc(val ?? '')}<\/textarea>`
: `<input type="text" data-path="${esc(path)}" data-type="${type}" value="${esc(val ?? '')}">`;
}
div.innerHTML = `
<label>${path}</label>
@@ -242,41 +242,54 @@ function parsePath(path) {
}
async function save() {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return; }
if (res.status === 409) {
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
location.reload();
}
return;
}
const json = await res.json();
const status = document.getElementById('saveStatus');
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
} else {
status.textContent = '❌ Hiba: ' + json.error;
try {
const data = collect();
const res = await fetch('/save?file=' + FILE, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': CSRF_TOKEN, 'X-Content-Hash': CONTENT_HASH },
body: JSON.stringify(data, null, 2)
});
if (res.status === 401) { location.href = '/login'; return false; }
if (res.status === 409) {
if (confirm('A tartalom megváltozott, mióta ez a lap megnyílt (pl. deploy vagy másik fül mentett).\n\nOK = lap frissítése az új tartalommal (a szerkesztésed elvész)\nMégse = maradsz ezen a lapon, a mentés nem történt meg.')) {
location.reload();
}
return false;
}
const json = await res.json();
if (json.ok) {
// Refresh the optimistic-lock fingerprint with the server-computed hash of
// the saved content, so the user's own subsequent saves don't trip 409.
if (json.contentHash) CONTENT_HASH = json.contentHash;
status.textContent = '✅ Mentve!';
status.style.color = '#10b981';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
return true;
} else {
status.textContent = '❌ Hiba: ' + json.error;
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
} catch (e) {
status.textContent = '❌ Hálózati hiba mentéskor';
status.style.color = '#f87171';
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 5000);
return false;
}
status.style.visibility = 'visible';
setTimeout(() => status.style.visibility = 'hidden', 3000);
}
async function publish() {
const btn = document.getElementById('publishBtn');
const status = document.getElementById('saveStatus');
// Save first
await save();
// Save first — abort publish if save failed (e.g. 409 conflict, validation error)
const saved = await save();
if (!saved) return;
// WHY: lock the button width and remember the label so the running state
// neither resizes the bottom bar nor permanently swaps the env-specific label.
+2 -1
View File
@@ -1,3 +1,4 @@
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// Page templates for the Content Editor. Kept separate so content-editor.js
// stays focused on routing/handling and below the file-size limits.
@@ -91,7 +92,7 @@ const HTML = (activeFile, jsonData, message, csrfToken, fileLabels, clientJs, co
<body>
${isStaging() ? '<div class="environment-banner">⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti</div>' : ''}
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${message.text}</div>` : ''}
${message ? `<div class="toast ${message.type === 'ok' ? 'ok' : 'err'}">${escHtml(message.text)}</div>` : ''}
<header>
<h1>mozdIT Content Editor</h1>
+3 -3
View File
@@ -18,9 +18,9 @@ const NO_CHANGES_MARKER = '__NO_CONTENT_CHANGES__';
function buildPublishCommand(commitMessage) {
return [
'git add .',
// WHY: logo uploads live in proto/public — outside the content cwd — so
// stage them too (tolerant: optional path in test throwaway repos).
'(git add ../public || true)',
// WHY: logo uploads live in proto/public — 2 levels above the content cwd — so
// stage them too (tolerant: optional path in test throwaway repos, stderr muted).
'(git add ../../public 2>/dev/null || true)',
`(git diff --cached --quiet && echo ${NO_CHANGES_MARKER} || git commit -m "${commitMessage}")`,
'(git pull --rebase origin main || (git rebase --abort; false))',
'git push origin main',
+7 -3
View File
@@ -8,13 +8,17 @@ const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
const sessions = new Map(); // token -> expiresAt (ms)
function timingSafeMatch(candidate, expected) {
if (typeof candidate !== 'string' || typeof expected !== 'string' || candidate.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(expected));
if (typeof candidate !== 'string' || typeof expected !== 'string') return false;
const cHash = crypto.createHash('sha256').update(candidate).digest();
const eHash = crypto.createHash('sha256').update(expected).digest();
return crypto.timingSafeEqual(cHash, eHash);
}
function validateLogin(user, pass, expectedUser, expectedPass) {
if (!expectedUser || !expectedPass) return false;
return timingSafeMatch(user, expectedUser) && timingSafeMatch(pass, expectedPass);
const userOk = timingSafeMatch(user, expectedUser);
const passOk = timingSafeMatch(pass, expectedPass);
return Boolean(userOk && passOk);
}
function createSessionCookie(isSecure) {
+4 -1
View File
@@ -16,7 +16,10 @@ function renderInline(text) {
return escapeHtml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, linkText, url) => {
const safeUrl = /^(https?:\/\/|mailto:|#|\/)/i.test(url) ? url : '#';
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${linkText}</a>`;
});
}
function renderMarkdown(markdown) {
+1 -1
View File
@@ -25,7 +25,7 @@ const ROOT = path.join(__dirname, '..');
// ── Unit ─────────────────────────────────────────────────────────────────────
const cmd = buildPublishCommand('content: frissítve a CMS-ből');
assert.ok(cmd.startsWith('git add . && (git add ../public || true) && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
assert.ok(cmd.startsWith('git add . && (git add ../../public 2>/dev/null || true) && (git diff --cached --quiet && echo ' + NO_CHANGES_MARKER), 'conditional commit with marker');
assert.ok(cmd.includes('(git pull --rebase origin main || (git rebase --abort; false))'), 'rebase-abort fallback');
assert.ok(cmd.endsWith('git push origin main'), 'push last');