From b74319c62733e0e4a35a509598892c76231efe2e Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:58:08 -0400 Subject: [PATCH] Fix: clear the stored password when protection is turned off Disabling Password Protection only flipped settings.auth.enabled. The hash stayed on disk, where it could not be replaced: set-password refuses while a hash exists unless the caller holds a session, no session can be obtained while auth is off, and login is a pure bypass that issues no cookie. A forgotten password could only be cleared by editing the config on the data volume. The hash also came back into force on re-enable, without the admin choosing it. Switching auth off now deletes the hash and the session secret, so tokens signed under it cannot be honoured if protection returns. The admin UI confirms before the save, drops its own passwordSet flag and shows the password as not set. Enabling still requires a stored password, so a cleared install must set a new one. --- CHANGELOG.md | 3 ++ api/src/routes/auth.js | 12 +++++++ api/test/auth-lockout.test.js | 59 +++++++++++++++++++++++++++++++++++ ui/i18n/de.json | 1 + ui/i18n/en.json | 1 + ui/i18n/es.json | 1 + ui/i18n/fa.json | 1 + ui/i18n/fr.json | 1 + ui/i18n/zh-Hans.json | 1 + ui/js/admin-settings.js | 16 +++++++++- 10 files changed, 95 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eba0110..ea400e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,9 @@ 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. + ### Security - A Connections tile that cannot reach a service now says so in plain words diff --git a/api/src/routes/auth.js b/api/src/routes/auth.js index 0f6ed628..839b2cff 100644 --- a/api/src/routes/auth.js +++ b/api/src/routes/auth.js @@ -171,9 +171,21 @@ on('POST', '/api/auth/toggle', async (req, res) => { return json(res, 400, { error: 'Set a password before turning authentication on.', kind: KIND.INVALID }); } cfg.settings.auth.enabled = !!enabled; + /* Turning protection off discards the password with it. Keeping the hash + strands the install: setting a new password needs a session, no session + 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. */ + const cleared = !enabled && !!cfg.settings.auth.passwordHash; + if (!enabled) { + delete cfg.settings.auth.passwordHash; + delete cfg.settings.auth.secret; + } if (enabled && !cfg.settings.auth.secret) cfg.settings.auth.secret = newSessionSecret(); saveConfig(cfg); log.audit('auth toggled', { enabled: !!enabled }); + if (cleared) log.audit('password cleared', {}); json(res, 200, { ok: true }); } catch (e) { fail(res, e, { status: 400 }); diff --git a/api/test/auth-lockout.test.js b/api/test/auth-lockout.test.js index e47d5889..fb997856 100644 --- a/api/test/auth-lockout.test.js +++ b/api/test/auth-lockout.test.js @@ -127,6 +127,65 @@ test('auth can always be switched off', async () => { assert.equal(loadConfig().settings.auth.enabled, false); }); +/* ── switching off discards the password ──────────────────────────────────── */ + +/* A disabled password that stays on disk is its own dead end: set-password + refuses while a hash exists unless the caller holds a session, and no session + can be obtained with auth off, so a forgotten password could only be replaced + by hand-editing the config. */ + +test('switching auth off clears the stored password and secret', async () => { + const cfg = loadConfig(); + cfg.settings.auth = { enabled: true, secret: SECRET, passwordHash: await hashPassword('correct-horse') }; + saveConfig(cfg); + + assert.equal( + (await req('POST', '/api/auth/toggle', { enabled: false }, 'ds=' + makeToken('s1', SECRET))).status, + 200, + ); + + const stored = loadConfig().settings.auth; + assert.equal(stored.passwordHash, undefined); + assert.equal(stored.secret, undefined); + assert.equal((await req('GET', '/api/auth/check')).body.passwordSet, false); +}); + +test('an orphaned hash left by an earlier version is cleared on the next switch off', async () => { + const cfg = loadConfig(); + cfg.settings.auth = { enabled: false, secret: SECRET, passwordHash: await hashPassword('correct-horse') }; + saveConfig(cfg); + + assert.equal((await req('POST', '/api/auth/toggle', { enabled: true })).status, 200, 'the old password still works'); + /* Auth is in force from here, so switching back off goes through the gate the + same way the admin would, with a session. The stored secret is untouched by + the enable, so a token signed with it is valid. */ + const cookie = 'ds=' + makeToken('s1', SECRET); + assert.equal((await req('POST', '/api/auth/toggle', { enabled: false }, cookie)).status, 200); + assert.equal(loadConfig().settings.auth.passwordHash, undefined); + + const r = await req('POST', '/api/auth/toggle', { enabled: true }); + assert.equal(r.status, 400, 're-enabling now needs a new password rather than reviving the cleared one'); + assert.match(r.body.error, /password/i); +}); + +test('a session token from before the switch off is not honoured again', async () => { + const cfg = loadConfig(); + cfg.settings.auth = { enabled: true, secret: SECRET, passwordHash: await hashPassword('correct-horse') }; + saveConfig(cfg); + const cookie = 'ds=' + makeToken('s1', SECRET); + + await req('POST', '/api/auth/toggle', { enabled: false }, cookie); + await req('POST', '/api/auth/set-password', { password: 'a-brand-new-one' }); + + assert.equal((await req('GET', '/api/config', null, cookie)).status, 401); +}); + +test('switching off with no password stored is not an error', async () => { + const r = await req('POST', '/api/auth/toggle', { enabled: false }); + assert.equal(r.status, 200); + assert.equal(loadConfig().settings.auth.enabled, false); +}); + /* ── an install already locked recovers itself ────────────────────────────── */ test('a locked install reports auth as off, matching how it behaves', async () => { diff --git a/ui/i18n/de.json b/ui/i18n/de.json index 43381ad5..124714d0 100644 --- a/ui/i18n/de.json +++ b/ui/i18n/de.json @@ -164,6 +164,7 @@ "confirm": { "deleteFolder": "Ordner „{name}“ löschen? Enthaltene Apps werden nicht gelöscht.", "revokeSessions": "Alle Geräte abmelden, auch dieses?", + "clearPassword": "Passwortschutz ausschalten? Das gespeicherte Passwort wird gelöscht, für ein erneutes Einschalten ist ein neues nötig.", "remove": "„{name}“ entfernen?" }, "list": { diff --git a/ui/i18n/en.json b/ui/i18n/en.json index 881938c1..76e002d2 100644 --- a/ui/i18n/en.json +++ b/ui/i18n/en.json @@ -164,6 +164,7 @@ "confirm": { "deleteFolder": "Delete folder \"{name}\"? Apps inside will not be deleted.", "revokeSessions": "Sign out all devices, including this one?", + "clearPassword": "Turn off Password Protection? The stored password is deleted, so turning it back on will need a new one.", "remove": "Remove \"{name}\"?" }, "list": { diff --git a/ui/i18n/es.json b/ui/i18n/es.json index aec551cd..0d2bfb8c 100644 --- a/ui/i18n/es.json +++ b/ui/i18n/es.json @@ -164,6 +164,7 @@ "confirm": { "deleteFolder": "¿Eliminar la carpeta \"{name}\"? Las aplicaciones que contiene no se eliminarán.", "revokeSessions": "¿Cerrar sesión en todos los dispositivos, incluido este?", + "clearPassword": "¿Desactivar la protección con contraseña? Se eliminará la contraseña guardada, por lo que volver a activarla requerirá una nueva.", "remove": "¿Eliminar \"{name}\"?" }, "list": { diff --git a/ui/i18n/fa.json b/ui/i18n/fa.json index 3d8792aa..91ea3e1e 100644 --- a/ui/i18n/fa.json +++ b/ui/i18n/fa.json @@ -164,6 +164,7 @@ "confirm": { "deleteFolder": "پوشه «{name}» حذف شود؟ برنامه‌های داخل آن حذف نخواهند شد.", "revokeSessions": "از همه دستگاه‌ها، از جمله همین دستگاه، خارج شوم؟", + "clearPassword": "محافظت با رمز عبور خاموش شود؟ رمز عبور ذخیره‌شده حذف می‌شود، بنابراین روشن کردن دوباره به رمز تازه‌ای نیاز دارد.", "remove": "«{name}» حذف شود؟" }, "list": { diff --git a/ui/i18n/fr.json b/ui/i18n/fr.json index a0deb628..c7210713 100644 --- a/ui/i18n/fr.json +++ b/ui/i18n/fr.json @@ -164,6 +164,7 @@ "confirm": { "deleteFolder": "Supprimer le dossier « {name} » ? Les applications qu'il contient ne seront pas supprimées.", "revokeSessions": "Déconnecter tous les appareils, y compris celui-ci ?", + "clearPassword": "Désactiver la protection par mot de passe ? Le mot de passe enregistré est supprimé, le réactiver en exigera un nouveau.", "remove": "Supprimer « {name} » ?" }, "list": { diff --git a/ui/i18n/zh-Hans.json b/ui/i18n/zh-Hans.json index 1468c2ff..f56794a2 100644 --- a/ui/i18n/zh-Hans.json +++ b/ui/i18n/zh-Hans.json @@ -164,6 +164,7 @@ "confirm": { "deleteFolder": "删除文件夹“{name}”?其中的应用不会被删除。", "revokeSessions": "退出所有设备,包括当前设备?", + "clearPassword": "关闭密码保护?已保存的密码将被删除,再次开启时需要设置新密码。", "remove": "删除“{name}”?" }, "list": { diff --git a/ui/js/admin-settings.js b/ui/js/admin-settings.js index f5f52fa4..91ffaccd 100644 --- a/ui/js/admin-settings.js +++ b/ui/js/admin-settings.js @@ -303,10 +303,15 @@ async function saveServer() { c.settings.logLevel = inp('log-level')?.value || 'info'; c.settings.language = inp('lang-sel')?.value || 'en'; const langChanged = c.settings.language !== prevLang; - await ap('/api/config', c); const pw = inp('sec-pw')?.value || ''; const enabled = inp('sec-en')?.checked || false; + /* Asked before the first request of the save, not next to the toggle: the + password survives until Save, so this is the moment it is really deleted, + and cancelling here must leave every other field unsaved too. */ + if (!enabled && _passwordSet && !confirm(t('confirm.clearPassword'))) return; + await ap('/api/config', c); + if (authEnableBlocked({ enabled, passwordSet: _passwordSet, newPassword: pw })) { toast(t('toast.authNeedsPassword'), 'err'); return; @@ -326,6 +331,15 @@ async function saveServer() { } } await ap('/api/auth/toggle', { enabled }); + if (!enabled) { + /* The server drops the password with the toggle, so the page must stop + claiming one is stored: re-enabling in this session needs a new one. */ + _passwordSet = false; + const pwValEl = el('ie-pw-v'); + if (pwValEl) pwValEl.textContent = 'Not set'; + const pwEl = inp('sec-pw'); + if (pwEl) pwEl.placeholder = ''; + } toast(t('toast.saved')); if (langChanged) location.reload(); } catch (e) {