diff --git a/content-editor.js b/content-editor.js index 9db5427..8b7d462 100644 --- a/content-editor.js +++ b/content-editor.js @@ -15,11 +15,11 @@ const { exec } = require('child_process'); const crypto = require('crypto'); const { validateContent } = require('./proto/src/content/schema'); -const PORT = 4001; +const PORT = Number(process.env.CONTENT_EDITOR_PORT) || 4001; const CONTENT_DIR = path.join(__dirname, 'proto', 'src', 'content'); const BACKUP_DIR = path.join(__dirname, '.content-backups'); const MAX_REQUEST_BODY_BYTES = 256 * 1024; -const AUDIT_LOG_FILE = path.join(__dirname, '.content-editor-audit.jsonl'); +const AUDIT_LOG_FILE = process.env.CONTENT_EDITOR_AUDIT_FILE || path.join(__dirname, '.content-editor-audit.jsonl'); const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; const AUTH_MAX_ATTEMPTS = 5; const PUBLISH_MAX_ATTEMPTS = 3; @@ -106,6 +106,8 @@ const HTML = (activeFile, jsonData, message, csrfToken) => ` /* Bottom bar */ .bottom-bar { position: fixed; bottom: 0; left: 0; right: 0; background: #0f1117; border-top: 1px solid #2d3748; padding: 14px 32px; display: flex; gap: 14px; align-items: center; z-index: 50; } + .btn-logout { margin-left: auto; background: #1f2937; color: #e2e8f0; border: 1px solid #374151; border-radius: 8px; padding: 9px 16px; font-size: 14px; cursor: pointer; } + .btn-logout:hover { background: #374151; } .btn-save { background: linear-gradient(135deg,#3b82f6,#6366f1); color: #fff; border: none; padding: 11px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; transition: opacity .2s, transform .1s; } .btn-save:hover { opacity: .9; transform: translateY(-1px); } .btn-save:active { transform: translateY(0); } @@ -149,6 +151,7 @@ ${message ? `
${messag 🔗 Előnézet → +
@@ -436,6 +439,16 @@ async function publish() { setTimeout(() => status.style.display = 'none', 5000); } +async function logout() { + // WHY: the browser caches Basic Auth credentials until it closes. One request with + // deliberately invalid credentials overwrites the cache, so the reload below + // prompts for login again. fetch() never triggers the native auth dialog. + try { + await fetch('/logout', { headers: { 'Authorization': 'Basic ' + btoa('logout:logout') } }); + } catch (e) { /* network error — reload anyway */ } + location.reload(); +} + function esc(v) { return String(v).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } @@ -523,6 +536,19 @@ const server = http.createServer(async (req, res) => { res.end('Content Editor is disabled: CMS_USER and CMS_PASS must be configured.'); return; } + + const u = new URL(req.url, `http://localhost:${PORT}`); + + // WHY: Basic Auth credentials are cached by the browser until it closes, so there is + // no native logout. The client calls /logout with deliberately invalid credentials, + // which overwrites the cached pair; the next navigation prompts for login again. + // Deliberately exempt from the auth rate limiter so logging out never locks the user out. + if (u.pathname === '/logout') { + res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); + res.end('Logged out'); + return; + } + if (!hasValidCredentials(req)) { const limited = exceedsRateLimit(`auth:${clientAddress}`, AUTH_MAX_ATTEMPTS); writeAudit('authentication_failed', { clientAddress, limited }); @@ -536,7 +562,6 @@ const server = http.createServer(async (req, res) => { return; } - const u = new URL(req.url, `http://localhost:${PORT}`); const fileKey = u.searchParams.get('file') || 'home'; const activeFile = FILES[fileKey] ? fileKey : 'home'; diff --git a/scripts/test-content-editor-logout.js b/scripts/test-content-editor-logout.js new file mode 100644 index 0000000..61acf6a --- /dev/null +++ b/scripts/test-content-editor-logout.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node + +/** + * Integration test for the Content Editor /logout endpoint. + * Spawns the real server on an ephemeral port and verifies: + * 1. /logout always answers 401 + WWW-Authenticate (invalidates cached Basic Auth) + * 2. /logout is exempt from the auth rate limiter (logging out never locks the user out) + * 3. the auth rate limiter still works for real failed logins (429 after the limit) + */ +const assert = require('assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawn } = require('child_process'); + +const PORT = 4123; +const BASE = `http://127.0.0.1:${PORT}`; +const ROOT = path.join(__dirname, '..'); +const AUDIT_FILE = path.join(os.tmpdir(), `content-editor-audit-test-${process.pid}.jsonl`); + +const env = { + ...process.env, + CONTENT_EDITOR_PORT: String(PORT), + CONTENT_EDITOR_AUDIT_FILE: AUDIT_FILE, + CMS_USER: 'logout-test-user', + CMS_PASS: 'logout-test-pass', + CMS_DEPLOY_ENV: 'staging', +}; + +const child = spawn('node', ['content-editor.js'], { cwd: ROOT, env, stdio: 'ignore' }); + +async function waitForServer(timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + // Poll /logout (rate-limit-free) so the readiness probe itself never + // consumes a failed-login attempt from the auth rate limiter. + await fetch(`${BASE}/logout`); + return; + } catch { + await new Promise(r => setTimeout(r, 200)); + } + } + throw new Error('server did not start'); +} + +async function main() { + await waitForServer(); + + // 1. /logout answers 401 with a challenge header, without credentials + const logoutRes = await fetch(`${BASE}/logout`); + assert.equal(logoutRes.status, 401); + assert.match(logoutRes.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/); + + // 2. /logout is exempt from the auth rate limiter: many logout calls must not + // consume the failed-login budget. + for (let i = 0; i < 10; i++) { + const res = await fetch(`${BASE}/logout`); + assert.equal(res.status, 401); + } + // A failed real login right after the logout flood must still be 401, not 429. + const failed = await fetch(`${BASE}/`, { + headers: { 'Authorization': 'Basic ' + Buffer.from('logout-test-user:wrong').toString('base64') }, + }); + assert.equal(failed.status, 401); + + // 3. The limiter still engages after repeated real failures (5 allowed, 6th → 429) + for (let i = 0; i < 4; i++) { + const res = await fetch(`${BASE}/`, { + headers: { 'Authorization': 'Basic ' + Buffer.from('logout-test-user:wrong').toString('base64') }, + }); + assert.equal(res.status, 401); + } + const limited = await fetch(`${BASE}/`, { + headers: { 'Authorization': 'Basic ' + Buffer.from('logout-test-user:wrong').toString('base64') }, + }); + assert.equal(limited.status, 429); + + // Valid credentials would now also be throttled — that is expected limiter behavior. + + console.log('Content Editor logout endpoint test: OK'); +} + +main() + .catch(err => { console.error('❌', err.message); process.exitCode = 1; }) + .finally(() => { + child.kill('SIGTERM'); + try { fs.unlinkSync(AUDIT_FILE); } catch { /* already gone */ } + });