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:
Chuck
2026-09-28 08:24:43 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent da5937da3d
commit bcef1957a9
15 changed files with 508 additions and 13 deletions
+33 -2
View File
@@ -50,7 +50,9 @@ from src.web_interface.validators import (
validate_file_upload
)
from src.common.permission_utils import install_requirements_file
from src.common.path_safety import resolve_under
from src.common.path_safety import resolve_under, safe_path_component
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
from src.backup_manager import BUNDLED_FONTS as _BUNDLED_FONTS
from src.device_location import DeviceLocationResolver, apply_device_location
_SUDO = shutil.which('sudo')
_JOURNALCTL = shutil.which('journalctl')
@@ -112,7 +114,15 @@ SYSTEM_FONTS = frozenset([
'10x20',
'matrixchunky8', 'matrixlight6', 'tom-thumb',
'clr6x12', 'helvr12', 'texgyre-27'
])
]) | frozenset(
# Every font the repository ships, from the list backups already keep in
# sync with assets/fonts/. The hand-written names above had drifted from
# it (MatrixChunky8X, MatrixLight6X, MatrixLight8X and ic8x8u were
# missing), so DELETE /fonts/<name> removed git-tracked fonts. Keys are
# the lowercased file stem, which is what the catalog and delete compare.
os.path.splitext(_name)[0].lower() for _name in _BUNDLED_FONTS
if _name.lower().endswith(('.ttf', '.otf', '.bdf'))
)
api_v3 = Blueprint('api_v3', __name__)
@@ -592,6 +602,27 @@ def _installed_plugin_ids():
instead of relying on the tracker's in-memory `get_all_*` view.
"""
return list(_discovered_plugin_manifests())
def _non_plugin_id_error(plugin_id):
"""Error response when ``plugin_id`` cannot name a plugin, else None.
Uninstall and config reset take the id from the request body and delete
or overwrite ``config[plugin_id]`` -- so ``{"plugin_id": "display"}``
removed the core display section and reported success. Core sections are
never plugin ids. The secrets-only core keys (``github`` holds the Plugin
Store token) are refused too, unless a plugin by that id is really
installed. Uninstall deliberately does not require the plugin to be
installed: it must still clean the config of one whose directory is gone.
"""
if safe_path_component(plugin_id) is None:
return error_response(ErrorCode.INVALID_INPUT,
f'Invalid plugin id: {plugin_id!r}', status_code=400)
if plugin_id in CORE_CONFIG_KEYS or (
plugin_id in CORE_SECRETS_KEYS
and plugin_id not in _discovered_plugin_manifests(plugin_id)):
return error_response(ErrorCode.INVALID_INPUT,
f"'{plugin_id}' is a core configuration section, not a plugin",
status_code=400)
return None
def _discovered_plugin_manifests(plugin_id=None, rescan=False):
"""The plugin manager's manifests, discovering plugins first if needed.
+21 -2
View File
@@ -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
+7 -1
View File
@@ -6,7 +6,7 @@ so their endpoint names are unchanged by living here.
from web_interface.blueprints.api_v3 import (
ErrorCode, OperationType, PROJECT_ROOT, Path, Response,
_CALENDAR_LIST_MAX_PAGES, _RENDERED_SECTION_FIELD, _SKIP_FIELD, _coerce_to_bool,
_do_transactional_uninstall, _enhance_schema_with_core_properties,
_do_transactional_uninstall, _enhance_schema_with_core_properties, _non_plugin_id_error,
_filter_config_by_schema, _get_plugin_version, _get_schema_property,
_hidden_array_item_property, _installed_plugin_ids, _is_plugin_update_available,
_plugin_directory,
@@ -1107,6 +1107,9 @@ def uninstall_plugin():
plugin_id = data['plugin_id']
preserve_config = data.get('preserve_config', False)
id_error = _non_plugin_id_error(plugin_id)
if id_error:
return id_error
# Both queued and direct paths use the same transactional helper so
# snapshot/rollback behaviour is consistent regardless of deployment.
@@ -2338,6 +2341,9 @@ def reset_plugin_config():
if not plugin_id:
return jsonify({'status': 'error', 'message': 'plugin_id required'}), 400
id_error = _non_plugin_id_error(plugin_id)
if id_error:
return id_error
# Get schema manager instance
schema_mgr = api_v3.schema_manager