mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +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:
+39
-4
@@ -102,6 +102,12 @@ _SINGLE_FILE_SECTIONS: Tuple[Tuple[str, Path, str], ...] = (
|
||||
("ytm_auth", _YTM_REL, "restore_wifi"),
|
||||
)
|
||||
|
||||
#: Sections holding credentials. Restored onto a device that has no copy yet,
|
||||
#: they would otherwise take the extracted temp file's umask mode (0o644,
|
||||
#: world-readable); 0o640 matches what config_manager_atomic gives secrets.
|
||||
_PRIVATE_SECTION_RELS = frozenset({_SECRETS_REL, _WIFI_REL, _YTM_REL})
|
||||
_PRIVATE_FILE_MODE = 0o640
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||
|
||||
@@ -235,6 +241,10 @@ def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
# Valid JSON that is not an object (a list, a bare string) would
|
||||
# raise AttributeError on .get() and abort the whole export.
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
plugin_id = data.get("id") or entry.name
|
||||
if plugin_id not in plugins:
|
||||
plugins[plugin_id] = {
|
||||
@@ -310,7 +320,11 @@ def create_backup(
|
||||
contents: List[str] = []
|
||||
|
||||
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||
# The name is unique per call: a fixed "<zip>.tmp" was shared by two
|
||||
# exports started in the same second, which then wrote the same file.
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(output_dir), prefix=f".{zip_name}.", suffix=".tmp")
|
||||
os.close(fd)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for section, rel, _flag in _SINGLE_FILE_SECTIONS:
|
||||
@@ -347,7 +361,24 @@ def create_backup(
|
||||
manifest = _build_manifest(contents)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
os.replace(tmp_path, zip_path)
|
||||
# Same-second exports share a timestamp; number the later one rather
|
||||
# than replacing the backup the first one just returned. The name is
|
||||
# claimed with an exclusive create (O_EXCL fails if it exists), so two
|
||||
# exports finishing together can't both pick the same free name; the
|
||||
# replace then swaps the finished archive in over our own placeholder.
|
||||
suffix = 2
|
||||
while True:
|
||||
try:
|
||||
os.close(os.open(zip_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600))
|
||||
break
|
||||
except FileExistsError:
|
||||
zip_path = output_dir / f"{Path(zip_name).stem}-{suffix}.zip"
|
||||
suffix += 1
|
||||
try:
|
||||
os.replace(tmp_path, zip_path)
|
||||
except BaseException:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
raise
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -493,7 +524,7 @@ def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
||||
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||
|
||||
|
||||
def _copy_file(src: Path, dst: Path) -> None:
|
||||
def _copy_file(src: Path, dst: Path, new_mode: Optional[int] = None) -> None:
|
||||
"""Replace ``dst`` with ``src``, atomically, without needing to own ``dst``.
|
||||
|
||||
``shutil.copy2`` opens the destination for writing, so it needs write
|
||||
@@ -509,6 +540,7 @@ def _copy_file(src: Path, dst: Path) -> None:
|
||||
|
||||
The destination's existing mode is preserved when there is one, so
|
||||
restoring secrets does not silently widen them to the umask default.
|
||||
When there is none, ``new_mode`` (if given) is used instead of ``src``'s.
|
||||
"""
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -530,6 +562,8 @@ def _copy_file(src: Path, dst: Path) -> None:
|
||||
shutil.copyfile(src, tmp_path)
|
||||
if existing_mode is not None:
|
||||
os.chmod(tmp_path, existing_mode)
|
||||
elif new_mode is not None:
|
||||
os.chmod(tmp_path, new_mode)
|
||||
else:
|
||||
shutil.copymode(src, tmp_path)
|
||||
if existing_owner is not None and hasattr(os, 'chown'):
|
||||
@@ -593,7 +627,8 @@ def restore_backup(
|
||||
result.skipped.append(section)
|
||||
continue
|
||||
try:
|
||||
_copy_file(tmp_dir / rel, project_root / rel)
|
||||
_copy_file(tmp_dir / rel, project_root / rel,
|
||||
new_mode=_PRIVATE_FILE_MODE if rel in _PRIVATE_SECTION_RELS else None)
|
||||
result.restored.append(section)
|
||||
except OSError as e:
|
||||
logger.error("[Backup] Failed to restore %s: %s", rel.name, e, exc_info=True)
|
||||
|
||||
@@ -1178,6 +1178,13 @@ class PluginStoreManager:
|
||||
other's freshly installed copy. The lock is reentrant because the
|
||||
rollback path already holds it when it calls in here.
|
||||
"""
|
||||
# Before anything touches the filesystem: plugin_id comes from the
|
||||
# request body, and the set-aside below moves plugins_dir / plugin_id
|
||||
# -- which for "../x" is a directory outside the plugins directory.
|
||||
if not self._is_valid_plugin_id(plugin_id):
|
||||
self.logger.error(f"Refusing to install invalid plugin id: {plugin_id!r}")
|
||||
return False
|
||||
|
||||
with self._get_reinstall_lock(plugin_id):
|
||||
plugin_path = self.plugins_dir / plugin_id
|
||||
if not plugin_path.exists():
|
||||
@@ -1345,6 +1352,13 @@ class PluginStoreManager:
|
||||
self.logger.error("Plugin manifest missing 'id' field")
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
# The manifest id becomes a directory name below (and the old
|
||||
# directory is removed to make room), so a downloaded manifest
|
||||
# saying "../x" must not steer that outside plugins_dir.
|
||||
if not self._is_valid_plugin_id(manifest_plugin_id):
|
||||
self.logger.error(f"Plugin manifest has an invalid 'id': {manifest_plugin_id!r}")
|
||||
self._safe_remove_directory(plugin_path)
|
||||
return False
|
||||
|
||||
# If manifest ID doesn't match directory name, rename directory to match manifest
|
||||
if manifest_plugin_id != plugin_id:
|
||||
@@ -1524,6 +1538,14 @@ class PluginStoreManager:
|
||||
'success': False,
|
||||
'error': 'No plugin ID found in manifest'
|
||||
}
|
||||
# plugin_id names the directory that is removed and then replaced
|
||||
# below, and it comes from the request body or a downloaded
|
||||
# manifest -- so "../x" would reach outside plugins_dir.
|
||||
if not self._is_valid_plugin_id(plugin_id):
|
||||
return {
|
||||
'success': False,
|
||||
'error': f'Invalid plugin ID: {plugin_id!r}'
|
||||
}
|
||||
|
||||
# Validate manifest has required fields
|
||||
required_fields = ['id', 'name', 'class_name', 'display_modes']
|
||||
|
||||
@@ -129,7 +129,17 @@ def validate_request_json(required_fields: list, data: Optional[Dict] = None) ->
|
||||
"Request body must be valid JSON",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
|
||||
# A JSON array passes the check above, and ``field in data`` then tests
|
||||
# list membership: ["plugin_id"] "had" every required field and the
|
||||
# handler's data['plugin_id'] raised TypeError -- a 500, not a 400.
|
||||
if not isinstance(data, dict):
|
||||
return None, error_response(
|
||||
ErrorCode.INVALID_INPUT,
|
||||
"Request body must be a JSON object",
|
||||
status_code=400
|
||||
)
|
||||
|
||||
missing_fields = [field for field in required_fields if field not in data]
|
||||
if missing_fields:
|
||||
return None, error_response(
|
||||
|
||||
@@ -64,7 +64,14 @@ def separate_secrets(
|
||||
secrets: Dict[str, Any] = {}
|
||||
for key, value in config.items():
|
||||
full_path = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(value, dict):
|
||||
# The field's own x-secret marker is checked before its type. A secret
|
||||
# whose value is an object or array used to fall into the recursion
|
||||
# below, where none of its children are marked, and was written to
|
||||
# config.json in plain text. mask_secret_fields already checks the
|
||||
# marker first, so this also matches what the API masks.
|
||||
if full_path in secret_paths:
|
||||
secrets[key] = value
|
||||
elif isinstance(value, dict):
|
||||
nested_regular, nested_secrets = separate_secrets(value, secret_paths, full_path)
|
||||
if nested_regular:
|
||||
regular[key] = nested_regular
|
||||
@@ -94,8 +101,6 @@ def separate_secrets(
|
||||
secrets[key] = sec_items
|
||||
else:
|
||||
regular[key] = value
|
||||
elif full_path in secret_paths:
|
||||
secrets[key] = value
|
||||
else:
|
||||
regular[key] = value
|
||||
return regular, secrets
|
||||
|
||||
Reference in New Issue
Block a user