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 @@