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
+28 -13
View File
@@ -30,6 +30,7 @@ const GUIDE_FILE = process.env.CONTENT_EDITOR_GUIDE_FILE || path.join(__dirname,
const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000;
const AUTH_MAX_ATTEMPTS = 5;
const PUBLISH_MAX_ATTEMPTS = 3;
let isPublishing = false;
const FILES = {
common: path.join(CONTENT_DIR, 'common.json'),
@@ -65,6 +66,8 @@ const clientJs = fs.readFileSync(path.join(__dirname, 'scripts', 'cms-editor-cli
const core = require('./scripts/cms-core');
const { CMS_USER, CMS_PASS, CMS_DEPLOY_ENV, CSRF_TOKEN, securityConfigIsValid, getClientAddress, hasValidCsrfToken, backupAndWriteAtomically } = core;
const exceedsRateLimit = (key, limit) => core.exceedsRateLimit(key, limit, RATE_LIMIT_WINDOW_MS);
const isRateLimited = (key, limit) => core.isRateLimited(key, limit, RATE_LIMIT_WINDOW_MS);
const recordRateLimitAttempt = key => core.recordRateLimitAttempt(key, RATE_LIMIT_WINDOW_MS);
const hasValidCredentials = req => core.hasValidCredentials(req, validateLogin);
const isAuthenticated = core.makeIsAuthenticated(hasValidSession, validateLogin);
const isBrowserNavigation = core.isBrowserNavigation;
@@ -82,6 +85,8 @@ function readDeployVersion() {
const DEPLOY_VERSION = readDeployVersion();
const server = http.createServer(async (req, res) => {
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('X-Content-Type-Options', 'nosniff');
const clientAddress = getClientAddress(req);
if (!securityConfigIsValid()) {
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' });
@@ -141,6 +146,12 @@ const server = http.createServer(async (req, res) => {
body += c;
});
req.on('end', () => {
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
let user = '';
let pass = '';
try {
@@ -157,12 +168,7 @@ const server = http.createServer(async (req, res) => {
res.end(JSON.stringify({ ok: true }));
return;
}
if (exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('login_failed', { clientAddress, result: 'rate_limited' });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok belépési kísérlet — próbáld újra később.' }));
return;
}
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('login_failed', { clientAddress, result: bodyTooLarge ? 'request_too_large' : 'invalid_credentials' });
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Hibás felhasználónév vagy jelszó.' }));
@@ -170,14 +176,15 @@ const server = http.createServer(async (req, res) => {
return;
}
if (isRateLimited(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS)) {
writeAudit('authentication_failed', { clientAddress, limited: true });
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end('Too many authentication attempts');
return;
}
if (!isAuthenticated(req)) {
const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS);
writeAudit('authentication_failed', { clientAddress, limited });
if (limited) {
res.writeHead(429, { 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end('Too many authentication attempts');
return;
}
recordRateLimitAttempt(`auth:${clientAddress}`);
writeAudit('authentication_failed', { clientAddress, limited: false });
// Browser navigations land on the styled login page; API/curl gets a plain 401.
// WHY no WWW-Authenticate: Safari pops its native auth dialog on fetch() calls
// that receive a Basic challenge — the styled /login page handles browsers.
@@ -283,7 +290,14 @@ const server = http.createServer(async (req, res) => {
// POST /publish — Git Commit, Pull Rebase & Push
if (req.method === 'POST' && u.pathname === '/publish') {
if (isPublishing) {
res.writeHead(423, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: false, error: 'Már folyamatban van egy publikálás. Kérlek, várj.' }));
return;
}
isPublishing = true;
if (exceedsRateLimit(`publish:${clientAddress}`, PUBLISH_MAX_ATTEMPTS)) {
isPublishing = false;
writeAudit('publish_rate_limited', { clientAddress, user: CMS_USER });
res.writeHead(429, { 'Content-Type': 'application/json', 'Retry-After': String(RATE_LIMIT_WINDOW_MS / 1000) });
res.end(JSON.stringify({ ok: false, error: 'Túl sok publikálási kísérlet' }));
@@ -294,6 +308,7 @@ const server = http.createServer(async (req, res) => {
// (WHY comments there): commit only when staged changes exist, rebase with
// abort-on-failure, deterministic no-changes marker instead of output matching.
exec(buildPublishCommand('content: frissítve a CMS-ből'), { cwd: CONTENT_DIR }, (error, stdout, stderr) => {
isPublishing = false;
res.writeHead(200, { 'Content-Type': 'application/json' });
const outcome = interpretPublishResult(error, stdout, stderr);
writeAudit('publish_finished', { clientAddress, user: CMS_USER, result: outcome.result });