From 534ac8117e12c0eb4c771b3537ea7d8c969f627b Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:18:47 -0400 Subject: [PATCH] Fix: stop a password being stored and deleted in one save Typing a password in the same save that switches Password Protection off stored it first, which rotates the session secret and signs every other device out, and the toggle then deleted it a moment later. The save reported success. The password is now left unwritten, and the field is cleared so it cannot read as one the dashboard holds. The toggle route accepts only a real true or false. An empty body, a null or a string used to be read as off, permanently deleting the stored password and secret over a request that said nothing. The sign-out row is resynced after protection is turned off, where it could stay on screen and answer with an error, and the change listener that kept it in step is attached again: it hung off an element that does not exist in the markup, so it never ran. The dead branch also wired the password strength meter, which admin.js already does. Password and Unsplash key status labels are translated rather than always reading in English. --- CHANGELOG.md | 17 +++++++- api/src/routes/auth.js | 16 +++++++- api/test/auth-lockout.test.js | 30 ++++++++++++++ ui/admin/index.html | 2 +- ui/i18n/de.json | 4 +- ui/i18n/en.json | 4 +- ui/i18n/es.json | 4 +- ui/i18n/fa.json | 4 +- ui/i18n/fr.json | 4 +- ui/i18n/zh-Hans.json | 4 +- ui/js/admin-logic.js | 8 ++++ ui/js/admin-settings.js | 73 ++++++++++++++++------------------- ui/js/admin.js | 4 +- ui/test/admin-logic.test.mjs | 23 +++++++++++ 14 files changed, 145 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd75a9c2..4428c521 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,11 +46,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Badges no longer lose their rounded shape in Safari after a long session. -- Turning Password Protection off now deletes the stored password, which - previously stayed behind and could leave an install unable to set a new one. +- Turning Password Protection off now deletes the stored password. + While protection is off, anyone who can reach the dashboard can set a new one. + +- A password typed in the same save that turns Password Protection off is no + longer stored and then immediately deleted, which signed out every other + device for nothing. + +- The sign-out row no longer stays on screen after Password Protection is + turned off, where pressing it returned an error. + +- The password and Unsplash key status labels are translated instead of always + reading in English. ### Security +- The Password Protection switch refuses a request that does not plainly say on + or off, instead of reading it as off and deleting the stored password. + - A Connections tile that cannot reach a service now says so in plain words instead of printing the address it tried, which put an internal host and port on the dashboard. diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index 839b2cff..9ea03f28 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -160,6 +160,13 @@ on('POST', '/api/auth/toggle', async (req, res) => { if (!checkOrigin(req, res)) return; try { const { enabled } = JSON.parse(await readBody(req)); + /* Only a real true or false. Turning protection off deletes the password + and cannot be undone from the UI, so a body that does not say so plainly, + from an old cached page or a script, must change nothing rather than + being read as "off". */ + if (typeof enabled !== 'boolean') { + return json(res, 400, { error: 'enabled must be true or false', kind: KIND.INVALID }); + } const cfg = loadConfig(); cfg.settings = cfg.settings || {}; cfg.settings.auth = cfg.settings.auth || {}; @@ -176,7 +183,14 @@ on('POST', '/api/auth/toggle', async (req, res) => { can be obtained while auth is off, and login is a bypass that issues no cookie, so a forgotten password could only be replaced by editing the config on disk. The secret goes too, so tokens signed under it cannot be - honoured if protection is turned back on later. */ + honoured if protection is turned back on later. + + The trade this makes: with no hash stored, set-password no longer demands + a session, so anyone who can reach the dashboard while protection is off + can set one and lock the owner out. That is accepted rather than guarded, + because protection being off already means anyone who can reach the + dashboard can read and rewrite the whole config; a guard here would only + look like security. It is why the toggle asks before clearing. */ const cleared = !enabled && !!cfg.settings.auth.passwordHash; if (!enabled) { delete cfg.settings.auth.passwordHash; diff --git a/api/test/auth-lockout.test.js b/api/test/auth-lockout.test.js index fb997856..d3000c05 100644 --- a/api/test/auth-lockout.test.js +++ b/api/test/auth-lockout.test.js @@ -239,3 +239,33 @@ test('login still refuses a wrong password when one is set', async () => { assert.equal((await req('POST', '/api/auth/login', { password: 'nope' })).status, 401); assert.equal((await req('POST', '/api/auth/login', { password: 'correct-horse' })).status, 200); }); + +/* ── the switch only answers to a real true or false ──────────────────────── */ + +/* Switching off deletes the password and the secret, and nothing in the UI can + undo it. A body that does not plainly say so, from a cached older page or a + script, used to be read as "off" and destroyed both. */ + +for (const [label, body] of [ + ['an empty body', {}], + ['a null flag', { enabled: null }], + ['a zero', { enabled: 0 }], + ['a string', { enabled: 'false' }], + ['the string "true"', { enabled: 'true' }], + ['a missing key with other fields', { enable: false }], +]) { + test(`${label} changes nothing rather than clearing the password`, async () => { + const cfg = loadConfig(); + const hash = await hashPassword('correct-horse'); + cfg.settings.auth = { enabled: true, secret: SECRET, passwordHash: hash }; + saveConfig(cfg); + + const r = await req('POST', '/api/auth/toggle', body, 'ds=' + makeToken('s1', SECRET)); + assert.equal(r.status, 400, 'refused'); + + const stored = loadConfig().settings.auth; + assert.equal(stored.passwordHash, hash, 'the password survives'); + assert.equal(stored.secret, SECRET, 'the secret survives'); + assert.equal(stored.enabled, true, 'the flag is untouched'); + }); +} diff --git a/ui/admin/index.html b/ui/admin/index.html index 262bdf8b..7b9b0f15 100644 --- a/ui/admin/index.html +++ b/ui/admin/index.html @@ -285,6 +285,6 @@