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