mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
fix(security): refuse unsafe plugin ids, keep secrets private, validate request bodies (#643)
* fix(security): refuse unsafe plugin ids, keep secrets private, validate bodies - install_from_url and the registry install's manifest rename refuse a plugin id that is not a single safe name (no ../ out of plugins_dir). - Uninstall and config reset refuse core config sections and ids with path parts; uninstall of a plugin whose directory is gone still works. - separate_secrets checks a field's own x-secret marker before recursing, so object/array secrets no longer land in config.json. - Backup restore creates missing secrets/wifi/ytm files with mode 640; export skips non-object manifests and no longer collides on same-second exports. - SYSTEM_FONTS includes every bundled font from BUNDLED_FONTS. - Raw config/secrets saves and validate_request_json require a JSON object. - A blank max_dynamic_duration_seconds keeps the stored value; other values are validated to 30-1800 instead of raising a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(security): validate the id before install_plugin moves anything; claim backup names atomically - install_plugin set aside plugins_dir / plugin_id before any id check, so "../x" moved a directory outside the plugins dir (the rollback moved it back, but only if the install path got that far) - two exports finishing in the same second could both see a free name and the later os.replace destroyed the first archive; the name is now claimed with O_EXCL before the archive is swapped in Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -687,10 +687,21 @@ def save_main_config():
|
||||
_set_checkbox(current_config['display'], 'use_short_date_format', 'use_short_date_format')
|
||||
|
||||
# Handle dynamic duration settings
|
||||
if 'max_dynamic_duration_seconds' in data:
|
||||
# The Display form posts this on every save, as "" when the box
|
||||
# was cleared; int("") was a 500 that lost the whole save. Blank
|
||||
# keeps the stored cap, and anything else is held to the form's
|
||||
# 30-1800 range instead of raising.
|
||||
max_dynamic = data.get('max_dynamic_duration_seconds')
|
||||
if max_dynamic is None or (isinstance(max_dynamic, str) and not max_dynamic.strip()):
|
||||
max_dynamic = None
|
||||
else:
|
||||
error = _hardware_int_error('max_dynamic_duration_seconds', 30, 1800)
|
||||
if error:
|
||||
return error
|
||||
if max_dynamic is not None:
|
||||
if 'dynamic_duration' not in current_config['display']:
|
||||
current_config['display']['dynamic_duration'] = {}
|
||||
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(data['max_dynamic_duration_seconds'])
|
||||
current_config['display']['dynamic_duration']['max_duration_seconds'] = int(max_dynamic)
|
||||
|
||||
# Handle double-sided display settings
|
||||
double_sided_fields = ['double_sided_enabled', 'double_sided_copies', 'double_sided_axis']
|
||||
@@ -1155,6 +1166,10 @@ def save_raw_main_config():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
# A JSON array or string parses fine and would be written over
|
||||
# config.json as-is, leaving a file nothing can load.
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'status': 'error', 'message': 'Configuration must be a JSON object'}), 400
|
||||
|
||||
was_auto_update_enabled = False
|
||||
try:
|
||||
@@ -1217,6 +1232,10 @@ def save_raw_secrets_config():
|
||||
return jsonify({'status': 'error', 'message': 'Invalid JSON in request body'}), 400
|
||||
if not data:
|
||||
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
|
||||
# strip_masked_values/deep_merge below expect an object; anything
|
||||
# else was a 500 at best and a replaced secrets file at worst.
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({'status': 'error', 'message': 'Secrets configuration must be a JSON object'}), 400
|
||||
|
||||
# The GET above masks what it returns, and this endpoint's only client
|
||||
# reads the whole file, edits one field and posts all of it back. So
|
||||
|
||||
Reference in New Issue
Block a user