From a7b1a2cab2556a5e9a3caaaa6e776a9860825e5e Mon Sep 17 00:00:00 2001 From: Do Siki Date: Tue, 18 Aug 2026 21:14:12 +0200 Subject: [PATCH] fix(cms): Safari-compatible authentication Safari-specific deviations fixed: 1. Safari shows its native auth dialog on fetch() calls answered with a 401 + WWW-Authenticate challenge (e.g. save with an expired session). All CMS 401 responses now omit WWW-Authenticate; browsers use the styled /login page instead. 2. Safari caches Basic credentials and resends them automatically, which made logout ineffective (a navigation after logout went straight back into the editor). Browser navigations (GET + text/html) now authenticate ONLY via the session cookie; Basic Auth remains valid for non-browser clients (curl, API). 3. /login and redirects send Cache-Control: no-store so Safari does not cache the login page or the 302. Closes MITHOME-62 --- content-editor.js | 26 +++++++++++++++++++------- scripts/test-content-editor-login.js | 20 ++++++++++++++++++-- scripts/test-content-editor-logout.js | 5 +++-- 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/content-editor.js b/content-editor.js index 6f0e16c..7c74ed8 100644 --- a/content-editor.js +++ b/content-editor.js @@ -95,7 +95,16 @@ function hasValidCredentials(req) { 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 isAuthenticated(req) { + if (isBrowserNavigation(req)) return hasValidSession(req); return hasValidCredentials(req) || hasValidSession(req); } @@ -139,7 +148,9 @@ const server = http.createServer(async (req, res) => { // 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' && req.method === 'GET') { - res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); + // Legacy cache-buster endpoint; no WWW-Authenticate — Safari would show its + // native auth dialog on any fetch hitting this challenge. + res.writeHead(401, { 'Cache-Control': 'no-store' }); res.end('Logged out'); return; } @@ -158,7 +169,7 @@ const server = http.createServer(async (req, res) => { // Public: styled login page (shown after logout and for unauthenticated browser visits). if (req.method === 'GET' && u.pathname === '/login') { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(LOGIN_PAGE()); return; } @@ -208,14 +219,15 @@ const server = http.createServer(async (req, res) => { res.end('Too many authentication attempts'); return; } - // Browser navigations land on the styled login page; API/curl keeps the 401 challenge. - const acceptsHtml = String(req.headers.accept || '').includes('text/html'); - if (acceptsHtml && req.method === 'GET') { - res.writeHead(302, { Location: '/login' }); + // 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. + if (isBrowserNavigation(req)) { + res.writeHead(302, { Location: '/login', 'Cache-Control': 'no-store' }); res.end(); return; } - res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="mozdIT CMS"' }); + res.writeHead(401, { 'Cache-Control': 'no-store' }); res.end('Access denied'); return; } diff --git a/scripts/test-content-editor-login.js b/scripts/test-content-editor-login.js index 06fafdc..3c025d9 100644 --- a/scripts/test-content-editor-login.js +++ b/scripts/test-content-editor-login.js @@ -116,10 +116,26 @@ async function main() { assert.equal(redirected.status, 302); assert.equal(redirected.headers.get('location'), '/login'); - // 6. non-browser requests keep the 401 challenge (curl/API compatibility) + // 6. non-browser requests get a plain 401 WITHOUT a Basic challenge + // (Safari pops its native auth dialog on challenged fetch calls). const apiStyle = await fetch(`${BASE}/`); assert.equal(apiStyle.status, 401); - assert.match(apiStyle.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/); + assert.equal(apiStyle.headers.get('www-authenticate'), null); + + // 7. Safari scenario: browser navigation with CACHED Basic credentials but no + // session must still land on /login — otherwise logout would be ineffective + // in browsers that resend Basic auth automatically. + const basic = 'Basic ' + Buffer.from('login-test-user:login-test-pass').toString('base64'); + const safariLike = await fetch(`${BASE}/`, { + headers: { Authorization: basic, Accept: 'text/html,application/xhtml+xml' }, + redirect: 'manual', + }); + assert.equal(safariLike.status, 302); + assert.equal(safariLike.headers.get('location'), '/login'); + + // 8. the same credentials DO authenticate a non-browser request (curl/API) + const curlLike = await fetch(`${BASE}/`, { headers: { Authorization: basic } }); + assert.equal(curlLike.status, 200); console.log('Content Editor login flow test: OK'); } finally { diff --git a/scripts/test-content-editor-logout.js b/scripts/test-content-editor-logout.js index 61acf6a..e69326c 100644 --- a/scripts/test-content-editor-logout.js +++ b/scripts/test-content-editor-logout.js @@ -47,10 +47,11 @@ async function waitForServer(timeoutMs = 10000) { async function main() { await waitForServer(); - // 1. /logout answers 401 with a challenge header, without credentials + // 1. /logout answers 401 without a Basic challenge (Safari would pop its + // native auth dialog on challenged fetch calls) const logoutRes = await fetch(`${BASE}/logout`); assert.equal(logoutRes.status, 401); - assert.match(logoutRes.headers.get('www-authenticate') || '', /Basic realm="mozdIT CMS"/); + assert.equal(logoutRes.headers.get('www-authenticate'), null); // 2. /logout is exempt from the auth rate limiter: many logout calls must not // consume the failed-login budget.