mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-18 12:59:12 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vmcwf5vMgYqdt8bJTZtiwb
This commit is contained in:
@@ -139,6 +139,23 @@ class TestRequestValidation:
|
|||||||
assert post(client, options="{}").status_code == 200
|
assert post(client, options="{}").status_code == 200
|
||||||
assert restore.call_args[0][2].restore_config is True
|
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:
|
class TestOptionsAreBooleanAware:
|
||||||
"""Regression: bool("false") is True in Python.
|
"""Regression: bool("false") is True in Python.
|
||||||
|
|||||||
@@ -90,6 +90,14 @@ def backup_validate():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("backup_validate failed: %s", e, exc_info=True)
|
logger.error("backup_validate failed: %s", e, exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An internal error occurred; see logs for details'}), 500
|
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'])
|
@api_v3.route('/backup/restore', methods=['POST'])
|
||||||
def backup_restore():
|
def backup_restore():
|
||||||
"""Restore a backup ZIP with optional RestoreOptions."""
|
"""Restore a backup ZIP with optional RestoreOptions."""
|
||||||
@@ -112,6 +120,12 @@ def backup_restore():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Invalid options: expected a JSON object',
|
'message': 'Invalid options: expected a JSON object',
|
||||||
}), 400
|
}), 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
|
# _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
|
# as JSON strings: bool("false") is True in Python, so a caller who
|
||||||
# explicitly asked to skip secrets would have had them restored
|
# explicitly asked to skip secrets would have had them restored
|
||||||
|
|||||||
Reference in New Issue
Block a user