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
+90
View File
@@ -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)