From 2f73367c6bdf2861fea18a42fd63f72784b1c076 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Thu, 27 Aug 2026 13:06:31 -0700 Subject: [PATCH] Add a guarded way to retire the plaintext shared password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit settings.json can still hold the old plaintext login_password after accounts exist, where it's ignored but sitting in cleartext. Removing it by hand has a sharp edge: with no accounts AND no login_password, the login route reads "nothing configured" as dev mode and lets anyone in without a password, so a tidy-up can quietly open the dashboard. `manage_users.py retire-shared-password` removes the key only when at least one account exists, and says so plainly when it won't. It also warns if LOGIN_PASSWORD is still set in the environment, since that's a second copy people forget. Needed delete_setting() in app.py — save_settings() only ever merges, so it had no way to take a key away. Verified: refuses with exit 1 when no accounts exist and leaves the file untouched; removes the key while preserving timezone and users; is idempotent; warns about the env var; and after removal only the account password signs in, with the retired shared password and the env value both rejected. Co-Authored-By: Claude Opus 5 --- app.py | 13 ++++++++++++ manage_users.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index 35dff5e..d94cebf 100644 --- a/app.py +++ b/app.py @@ -45,6 +45,19 @@ def save_settings(data): with open(SETTINGS_FILE, "w") as f: json.dump(existing, f, indent=2) +def delete_setting(key): + """Remove a key outright. save_settings() only ever merges, so it can't + take something away — used to retire the plaintext shared password once + real accounts exist. Returns True if the key was there.""" + settings = load_settings() + if key not in settings: + return False + del settings[key] + os.makedirs(os.path.dirname(SETTINGS_FILE) or ".", exist_ok=True) + with open(SETTINGS_FILE, "w") as f: + json.dump(settings, f, indent=2) + return True + app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) diff --git a/manage_users.py b/manage_users.py index 1e64dcd..22f804e 100644 --- a/manage_users.py +++ b/manage_users.py @@ -12,10 +12,17 @@ Run it inside the container, where Werkzeug and the real settings.json are: docker exec -it arr-summary python3 manage_users.py add docker exec -it arr-summary python3 manage_users.py passwd docker exec -it arr-summary python3 manage_users.py remove + docker exec -it arr-summary python3 manage_users.py retire-shared-password The first `add` switches the dashboard from its single shared password to per-user logins. Until then the old LOGIN_PASSWORD keeps working, so you can't lock yourself out by deploying this. + +Once accounts exist and you've confirmed they sign in, +`retire-shared-password` deletes the old plaintext login_password from +settings.json. It refuses to run while no accounts exist, because with +nothing configured at all the dashboard treats that as dev mode and lets +anyone in without a password. """ import sys @@ -24,7 +31,10 @@ import getpass from app import ( SETTINGS_FILE, PASSWORD_HASH_METHOD, + LOGIN_PASSWORD, save_settings, + delete_setting, + load_settings, load_users, generate_password_hash, ) @@ -122,7 +132,49 @@ def cmd_remove(args): return 0 -COMMANDS = {"list": cmd_list, "add": cmd_add, "passwd": cmd_passwd, "remove": cmd_remove} +def cmd_retire_shared_password(_args): + """Delete the plaintext login_password from settings.json. + + Guarded, because removing it while nothing else is configured makes + get_active_password() empty — and the login route reads "no credentials + at all" as dev mode and lets anyone straight in. + """ + users = load_users() + if not users: + print("Refusing: no accounts exist yet, so the shared password is still", + file=sys.stderr) + print("the only way in. Run `add ` first and check you can", + file=sys.stderr) + print("sign in with it, then run this again.", file=sys.stderr) + return 1 + + if "login_password" not in load_settings(): + print("Nothing to do — settings.json has no login_password.") + if LOGIN_PASSWORD: + print() + print("Note: LOGIN_PASSWORD is still set in the environment. It's") + print("ignored while accounts exist, but clear it from .env too if") + print("you want the old shared password gone entirely.") + return 0 + + delete_setting("login_password") + print(f"Removed the plaintext login_password from {SETTINGS_FILE}.") + print(f"{len(users)} account(s) remain in use: {', '.join(sorted(users))}") + if LOGIN_PASSWORD: + print() + print("LOGIN_PASSWORD is still set in the environment. It's ignored while") + print("accounts exist, but clear it from .env and restart to be rid of it.") + print("Don't clear it before you've confirmed the accounts work.") + return 0 + + +COMMANDS = { + "list": cmd_list, + "add": cmd_add, + "passwd": cmd_passwd, + "remove": cmd_remove, + "retire-shared-password": cmd_retire_shared_password, +} def main(argv):