diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ac7467..9f6700c 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: - name: ⏳ Wait for services to be ready run: | echo "Waiting for services to start..." - timeout 120 bash -c 'until curl -f http://localhost:3000/api/health; do sleep 2; done' + timeout 120 bash -c 'until curl -f http://localhost:8080/api/health; do sleep 2; done' - name: 🧪 Run integration tests working-directory: ./proto diff --git a/TODO.md b/TODO.md index aab7412..66c5cff 100755 --- a/TODO.md +++ b/TODO.md @@ -54,6 +54,7 @@ Next.js 15 alapú weboldal a mozdIT Bt. számára, Docker Compose-szal deployolv | MITHOME-73 | Lábléc/Kapcsolat cím CMS-szerkeszthetővé tétele (common.json footer.address) | ✅ | | MITHOME-74 | CMS publish deploy: hiányzó cwd — a deploy a gyökérben landolt | ✅ | | MITHOME-75 | CMS: gyorsbillentyűk (Ctrl+S Mentés, Ctrl+P Publikálás, Ctrl+Shift+V Verziók, ? súgó) | ✅ | +| MITHOME-76 | CMS: publish public path javítása, brute-force & timing attack védelem, Docker localhost bind | ✅ | --- diff --git a/content-editor.js b/content-editor.js index d292c4a..d675bfb 100644 --- a/content-editor.js +++ b/content-editor.js @@ -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 }); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e78e1a6..86960a8 100755 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -9,7 +9,7 @@ services: target: builder # Use builder stage for development container_name: mozdit-app-dev ports: - - "8080:3000" + - "127.0.0.1:8080:3000" environment: - NODE_ENV=development - MONGODB_URI=mongodb://mongodb:27017/mozdit @@ -36,7 +36,7 @@ services: image: mongo:7.0 container_name: mozdit-mongodb-dev ports: - - "27018:27017" + - "127.0.0.1:27018:27017" environment: - MONGO_INITDB_ROOT_USERNAME=admin - MONGO_INITDB_ROOT_PASSWORD=password123 @@ -53,7 +53,7 @@ services: image: mongo-express:1.0.2 container_name: mozdit-mongo-express-dev ports: - - "8081:8081" + - "127.0.0.1:8081:8081" environment: - ME_CONFIG_MONGODB_ADMINUSERNAME=admin - ME_CONFIG_MONGODB_ADMINPASSWORD=password123 @@ -70,7 +70,7 @@ services: image: grafana/loki:2.9.0 container_name: mozdit-loki-dev ports: - - "3100:3100" + - "127.0.0.1:3100:3100" command: -config.file=/etc/loki/local-config.yaml volumes: - loki_data:/loki @@ -83,7 +83,7 @@ services: image: grafana/grafana:10.2.0 container_name: mozdit-grafana-dev ports: - - "3001:3000" + - "127.0.0.1:3001:3000" environment: - GF_SECURITY_ADMIN_PASSWORD=admin123 volumes: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 066c4c5..76640de 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,7 +10,7 @@ services: - DEPLOY_VERSION=${DEPLOY_VERSION:-unversioned} container_name: mozdit-app-prod ports: - - "8080:3000" # Host port 8080 elkerüli a lokális npm dev (3000) összeakadást + - "127.0.0.1:8080:3000" # Belső port — csak nginx reverse proxy-n keresztül elérhető environment: - NODE_ENV=production # No fallback for MONGODB_URI: with root auth enabled on the Mongo container an @@ -37,7 +37,7 @@ services: image: mongo:7.0 container_name: mozdit-mongodb-prod ports: - - "27018:27017" # Host port 27018 elkerüli az összeakadást a lokális Mongo-val + - "127.0.0.1:27018:27017" # Belső port, nem publikus az internet felé environment: - MONGO_INITDB_ROOT_USERNAME=${MONGO_ROOT_USER:-admin} # No weak default password: a missing value must fail the container loudly. diff --git a/scripts/cms-core.js b/scripts/cms-core.js index b862d1a..ffa2058 100644 --- a/scripts/cms-core.js +++ b/scripts/cms-core.js @@ -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, diff --git a/scripts/cms-editor-client.js b/scripts/cms-editor-client.js index 0717e63..d7f651e 100644 --- a/scripts/cms-editor-client.js +++ b/scripts/cms-editor-client.js @@ -29,13 +29,13 @@ function renderPrimitive(path, val, container) { const type = val === null ? 'null' : typeof val; let control; if (type === 'boolean') { - control = ``; + control = ``; } else if (type === 'number') { - control = ``; + control = ``; } else { control = isLong - ? `${esc(val ?? '')}<\/textarea>` - : ``; + ? `${esc(val ?? '')}<\/textarea>` + : ``; } div.innerHTML = ` ${path} @@ -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. diff --git a/scripts/cms-pages.js b/scripts/cms-pages.js index 3f35f46..05e0849 100644 --- a/scripts/cms-pages.js +++ b/scripts/cms-pages.js @@ -1,3 +1,4 @@ +function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // 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 ${isStaging() ? '⚠ STAGING / TESZTKÖRNYEZET — itt végzett publikálás csak a staging oldalt frissíti' : ''} -${message ? `${message.text}` : ''} +${message ? `${escHtml(message.text)}` : ''} mozdIT Content Editor diff --git a/scripts/cms-publish.js b/scripts/cms-publish.js index 86dc94a..764d505 100644 --- a/scripts/cms-publish.js +++ b/scripts/cms-publish.js @@ -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', diff --git a/scripts/cms-session.js b/scripts/cms-session.js index fcc1e75..1b4d9d7 100644 --- a/scripts/cms-session.js +++ b/scripts/cms-session.js @@ -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) { diff --git a/scripts/markdown-render.js b/scripts/markdown-render.js index f59f561..fb56358 100644 --- a/scripts/markdown-render.js +++ b/scripts/markdown-render.js @@ -16,7 +16,10 @@ function renderInline(text) { return escapeHtml(text) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') - .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '$1'); + .replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, linkText, url) => { + const safeUrl = /^(https?:\/\/|mailto:|#|\/)/i.test(url) ? url : '#'; + return `${linkText}`; + }); } function renderMarkdown(markdown) { diff --git a/scripts/test-cms-publish.js b/scripts/test-cms-publish.js index 9de401f..9986e30 100644 --- a/scripts/test-cms-publish.js +++ b/scripts/test-cms-publish.js @@ -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');
$1