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.