mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35: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:
@@ -417,3 +417,93 @@ def test_restore_still_carries_the_previous_owner_across(
|
||||
assert result.success, result.errors
|
||||
owners = {(c.args[1], c.args[2]) for c in chown.call_args_list}
|
||||
assert owners == {(old.st_uid, old.st_gid)}
|
||||
|
||||
|
||||
def test_restore_onto_a_fresh_device_keeps_secrets_private(
|
||||
project: Path, empty_project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""With no existing file to take a mode from, the restored file took the
|
||||
extracted temp file's umask mode -- 0o644, so config_secrets.json,
|
||||
wifi_config.json and ytm_auth.json came back world-readable.
|
||||
|
||||
Recorded through os.chmod because Windows cannot represent 0o640.
|
||||
"""
|
||||
zip_path = create_backup(project, output_dir=tmp_path / "exports")
|
||||
chmods = []
|
||||
real_chmod = os.chmod
|
||||
|
||||
def recording_chmod(path, mode, *args, **kwargs):
|
||||
chmods.append((Path(path).name.lstrip("."), mode))
|
||||
return real_chmod(path, mode, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(os, "chmod", recording_chmod)
|
||||
|
||||
result = restore_backup(zip_path, empty_project, RestoreOptions(
|
||||
restore_fonts=False, restore_plugin_uploads=False, reinstall_plugins=False,
|
||||
))
|
||||
|
||||
assert result.success, result.errors
|
||||
private = {name.split(".json")[0]: mode for name, mode in chmods
|
||||
if name.startswith(("config_secrets.json", "wifi_config.json", "ytm_auth.json"))}
|
||||
assert private == {"config_secrets": 0o640, "wifi_config": 0o640, "ytm_auth": 0o640}
|
||||
assert all(mode != 0o640 for name, mode in chmods if name.startswith("config.json"))
|
||||
|
||||
|
||||
def test_a_manifest_that_is_not_an_object_is_skipped(project: Path) -> None:
|
||||
broken = project / "plugin-repos" / "broken"
|
||||
broken.mkdir()
|
||||
(broken / "manifest.json").write_text("[1, 2]", encoding="utf-8")
|
||||
|
||||
ids = [p["plugin_id"] for p in list_installed_plugins(project)]
|
||||
|
||||
assert "my-plugin" in ids and "broken" not in ids
|
||||
|
||||
|
||||
def test_same_second_exports_do_not_overwrite_each_other(
|
||||
project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from datetime import datetime as real_datetime
|
||||
|
||||
class FrozenDatetime:
|
||||
@staticmethod
|
||||
def now(*a, **k):
|
||||
return real_datetime(2026, 1, 2, 3, 4, 5)
|
||||
|
||||
monkeypatch.setattr(backup_manager, "datetime", FrozenDatetime)
|
||||
out = tmp_path / "exports"
|
||||
|
||||
first = create_backup(project, output_dir=out)
|
||||
second = create_backup(project, output_dir=out)
|
||||
|
||||
assert first != second
|
||||
assert first.exists() and second.exists()
|
||||
assert second.name == first.name[:-len(".zip")] + "-2.zip"
|
||||
assert not list(out.glob("*.tmp"))
|
||||
|
||||
|
||||
def test_export_name_is_claimed_atomically(
|
||||
project: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
# Two exports racing both see "no such file" before either publishes.
|
||||
# Simulate that by making exists() always say no: the name must still be
|
||||
# claimed exclusively, so the second export gets -2 instead of replacing
|
||||
# the first archive.
|
||||
from datetime import datetime as real_datetime
|
||||
|
||||
class FrozenDatetime:
|
||||
@staticmethod
|
||||
def now(*a, **k):
|
||||
return real_datetime(2026, 1, 2, 3, 4, 5)
|
||||
|
||||
monkeypatch.setattr(backup_manager, "datetime", FrozenDatetime)
|
||||
out = tmp_path / "exports"
|
||||
first = create_backup(project, output_dir=out)
|
||||
first_bytes = first.read_bytes()
|
||||
|
||||
monkeypatch.setattr(Path, "exists", lambda self: False)
|
||||
second = create_backup(project, output_dir=out)
|
||||
monkeypatch.undo()
|
||||
|
||||
assert second != first
|
||||
assert first.read_bytes() == first_bytes
|
||||
assert zipfile.is_zipfile(second)
|
||||
|
||||
Reference in New Issue
Block a user