From 6e06b60615336377b084b4188997a21602868f26 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 09:31:01 +0000 Subject: [PATCH] fix(api-v3): reject unknown restore option keys CodeRabbit's review of the blueprint split (#553) asked that POST /backup/restore reject option keys outside RestoreOptions' known set. The follow-up commit fixed the bool("false")-is-True bug with _coerce_to_bool but never added the key check: a typo'd or renamed key (e.g. "restoreSecrets") is silently ignored by opts_dict.get(key, True), so the flag stays at its True default and secrets get restored despite the caller's request saying otherwise -- with no indication anything was wrong. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb --- .../web_interface/test_api_v3_backup_restore.py | 17 +++++++++++++++++ web_interface/blueprints/api_v3/backup.py | 14 ++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/test/web_interface/test_api_v3_backup_restore.py b/test/web_interface/test_api_v3_backup_restore.py index 52758fba..4c4a8304 100644 --- a/test/web_interface/test_api_v3_backup_restore.py +++ b/test/web_interface/test_api_v3_backup_restore.py @@ -139,6 +139,23 @@ class TestRequestValidation: assert post(client, options="{}").status_code == 200 assert restore.call_args[0][2].restore_config is True + def test_unknown_option_key_is_refused(self, client, restore): + # Regression: opts_dict.get('restore_secrets', True) silently + # ignores a typo'd/renamed key like "restoreSecrets" and keeps the + # True default, restoring secrets a caller's request clearly meant + # to exclude -- with no indication anything was wrong. + response = post(client, options=json.dumps({"restoreSecrets": False})) + assert response.status_code == 400 + assert "Unknown restore option" in response.get_json()["message"] + assert "restoreSecrets" in response.get_json()["message"] + restore.assert_not_called() + + def test_known_and_unknown_keys_together_are_refused(self, client, restore): + response = post(client, options=json.dumps({ + "restore_secrets": False, "restore_everything": True})) + assert response.status_code == 400 + restore.assert_not_called() + class TestOptionsAreBooleanAware: """Regression: bool("false") is True in Python. diff --git a/web_interface/blueprints/api_v3/backup.py b/web_interface/blueprints/api_v3/backup.py index f971f0e7..53e3a12d 100644 --- a/web_interface/blueprints/api_v3/backup.py +++ b/web_interface/blueprints/api_v3/backup.py @@ -90,6 +90,14 @@ def backup_validate(): except Exception as e: logger.error("backup_validate failed: %s", e, exc_info=True) return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500 +#: The only keys RestoreOptions recognizes. A typo'd or renamed key (e.g. +#: "restoreSecrets") would otherwise be silently ignored by opts_dict.get(), +#: leaving that flag at its True default -- restoring secrets a caller's +#: request clearly meant to exclude, with no indication anything was wrong. +_RESTORE_OPTION_KEYS = frozenset(( + 'restore_config', 'restore_secrets', 'restore_wifi', 'restore_fonts', + 'restore_plugin_uploads', 'reinstall_plugins', +)) @api_v3.route('/backup/restore', methods=['POST']) def backup_restore(): """Restore a backup ZIP with optional RestoreOptions.""" @@ -112,6 +120,12 @@ def backup_restore(): 'status': 'error', 'message': 'Invalid options: expected a JSON object', }), 400 + unknown_keys = set(opts_dict) - _RESTORE_OPTION_KEYS + if unknown_keys: + return jsonify({ + 'status': 'error', + 'message': f'Unknown restore option(s): {", ".join(sorted(unknown_keys))}', + }), 400 # _coerce_to_bool (not bare bool()) because a request can send these # as JSON strings: bool("false") is True in Python, so a caller who # explicitly asked to skip secrets would have had them restored