mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-13 14:48:06 +00:00
test(api): cover backup restore and path containment, and fix restore scope
Restore is the most destructive thing the web interface can do — it
overwrites config, secrets, WiFi settings and fonts, then reinstalls
plugins — and neither it nor the file routes beside it had tests.
A malformed `options` field fell back to {}. Every RestoreOptions flag
defaults to True, so a caller who asked for a narrow restore and
mis-serialized the request got a full one instead, secrets included, and
was told it succeeded. Valid JSON that is not an object was worse:
`"null"` or `"[1,2]"` reached .get() on a non-dict and raised, so the
request died as a generic 500. Both are now refused with a 400 that says
what was wrong, and restore_backup is never reached.
The other file routes take a filename straight out of the URL and turn it
into a path — one to read, one to unlink. _safe_backup_path is the only
thing keeping those inside the export directory, and it was untested. No
bypass was found; the thirteen traversal shapes are pinned so a later
loosening of that pattern has to argue with something. The delete route's
by-name enumeration is covered too, including that a directory sharing a
backup's name is not removed.
84 tests. Two behaviours are pinned as intentional: a failed plugin
reinstall turns the whole restore into an error even though file
restoration succeeded, and omitting `options` entirely still means
restore everything — that is the documented default, and it is only the
mis-serialized case that was wrong.
Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Path-containment tests for the backup file routes:
|
||||
GET /backup/download/<filename>, DELETE /backup/<filename>, and the
|
||||
listing/validation routes alongside them.
|
||||
|
||||
Both filename routes take user input straight from the URL and turn it
|
||||
into a filesystem path, one to read and one to unlink. `_safe_backup_path`
|
||||
is what stops that from reaching outside the export directory, and it had
|
||||
no tests.
|
||||
|
||||
This is verification of existing containment, not a fix: no bypass was
|
||||
found. The tests exist so that a later "just let dots through" change has
|
||||
to argue with something.
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
project_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
_SENTINEL = object()
|
||||
|
||||
# Anything that tries to name a file outside the export directory, or that
|
||||
# is not a plain <name>.zip.
|
||||
TRAVERSAL_ATTEMPTS = [
|
||||
"../../etc/passwd",
|
||||
"../config.json",
|
||||
"..%2f..%2fetc%2fpasswd",
|
||||
"....//....//etc/passwd",
|
||||
"/etc/passwd",
|
||||
"..\\..\\config.json",
|
||||
"backup.zip/../../../etc/passwd",
|
||||
".hidden.zip",
|
||||
"backup.txt",
|
||||
"backup.zip.exe",
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
export_dir = tmp_path / "backups"
|
||||
export_dir.mkdir()
|
||||
monkeypatch.setattr(api_v3_module, "_BACKUP_EXPORT_DIR", export_dir)
|
||||
|
||||
# A file outside the export dir that a traversal would be reaching for.
|
||||
secret = tmp_path / "config.json"
|
||||
secret.write_text(json.dumps({"secret": "do not touch"}))
|
||||
|
||||
originals = {name: getattr(api_v3, name, _SENTINEL) for name in _MANAGER_ATTRS}
|
||||
for name in _MANAGER_ATTRS:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
class Env:
|
||||
pass
|
||||
|
||||
e = Env()
|
||||
e.client = app.test_client()
|
||||
e.export_dir = export_dir
|
||||
e.secret = secret
|
||||
yield e
|
||||
|
||||
for name, original in originals.items():
|
||||
if original is _SENTINEL:
|
||||
if hasattr(api_v3, name):
|
||||
delattr(api_v3, name)
|
||||
else:
|
||||
setattr(api_v3, name, original)
|
||||
|
||||
|
||||
def make_backup(export_dir, name="backup-2026-01-01.zip"):
|
||||
path = export_dir / name
|
||||
path.write_bytes(b"PK\x03\x04fake zip")
|
||||
return path
|
||||
|
||||
|
||||
class TestSafeBackupPath:
|
||||
"""The containment helper itself."""
|
||||
|
||||
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
|
||||
def test_rejects_unsafe_names(self, env, filename):
|
||||
assert api_v3_module._safe_backup_path(filename) is None
|
||||
|
||||
def test_rejects_none(self, env):
|
||||
assert api_v3_module._safe_backup_path(None) is None
|
||||
|
||||
@pytest.mark.parametrize("filename", [
|
||||
"backup.zip",
|
||||
"backup-2026-01-01.zip",
|
||||
"backup_2026.01.01-v2.zip",
|
||||
"a.zip",
|
||||
])
|
||||
def test_accepts_plain_zip_names(self, env, filename):
|
||||
resolved = api_v3_module._safe_backup_path(filename)
|
||||
assert resolved is not None
|
||||
assert resolved.parent == env.export_dir.resolve()
|
||||
|
||||
def test_result_is_always_inside_the_export_dir(self, env):
|
||||
resolved = api_v3_module._safe_backup_path("backup.zip")
|
||||
resolved.relative_to(env.export_dir.resolve()) # raises if outside
|
||||
|
||||
def test_overlong_name_rejected(self, env):
|
||||
assert api_v3_module._safe_backup_path("a" * 250 + ".zip") is None
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_downloads_an_existing_backup(self, env):
|
||||
make_backup(env.export_dir)
|
||||
response = env.client.get("/api/v3/backup/download/backup-2026-01-01.zip")
|
||||
assert response.status_code == 200
|
||||
assert response.data == b"PK\x03\x04fake zip"
|
||||
|
||||
def test_missing_file_is_a_404(self, env):
|
||||
response = env.client.get("/api/v3/backup/download/never-made.zip")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
|
||||
def test_traversal_attempts_are_refused(self, env, filename):
|
||||
response = env.client.get(f"/api/v3/backup/download/{filename}")
|
||||
# However the request is turned away — 404 from the containment
|
||||
# check, or 308/405 from routing never matching at all — what
|
||||
# matters is that no file outside the export directory is served.
|
||||
assert response.status_code != 200
|
||||
assert b"do not touch" not in response.data
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_deletes_an_existing_backup(self, env):
|
||||
path = make_backup(env.export_dir)
|
||||
response = env.client.delete("/api/v3/backup/backup-2026-01-01.zip")
|
||||
assert response.status_code == 200
|
||||
assert not path.exists()
|
||||
|
||||
def test_missing_file_is_a_404(self, env):
|
||||
response = env.client.delete("/api/v3/backup/never-made.zip")
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.parametrize("filename", TRAVERSAL_ATTEMPTS)
|
||||
def test_traversal_attempts_delete_nothing(self, env, filename):
|
||||
response = env.client.delete(f"/api/v3/backup/{filename}")
|
||||
assert response.status_code != 200
|
||||
assert env.secret.exists() # the file a traversal was aiming at
|
||||
|
||||
def test_only_the_named_backup_is_removed(self, env):
|
||||
keep = make_backup(env.export_dir, "keep.zip")
|
||||
drop = make_backup(env.export_dir, "drop.zip")
|
||||
env.client.delete("/api/v3/backup/drop.zip")
|
||||
assert keep.exists()
|
||||
assert not drop.exists()
|
||||
|
||||
def test_directory_with_a_matching_name_is_not_removed(self, env):
|
||||
# The delete loop matches by name but requires a regular file.
|
||||
(env.export_dir / "sneaky.zip").mkdir()
|
||||
response = env.client.delete("/api/v3/backup/sneaky.zip")
|
||||
assert response.status_code == 404
|
||||
assert (env.export_dir / "sneaky.zip").is_dir()
|
||||
|
||||
|
||||
class TestList:
|
||||
def test_lists_only_zip_files(self, env):
|
||||
make_backup(env.export_dir, "one.zip")
|
||||
(env.export_dir / "notes.txt").write_text("ignore me")
|
||||
response = env.client.get("/api/v3/backup/list")
|
||||
assert response.status_code == 200
|
||||
names = [entry["filename"] for entry in response.get_json()["data"]]
|
||||
assert names == ["one.zip"]
|
||||
|
||||
def test_empty_directory_lists_nothing(self, env):
|
||||
response = env.client.get("/api/v3/backup/list")
|
||||
assert response.get_json()["data"] == []
|
||||
|
||||
def test_entries_carry_size_and_timestamp(self, env):
|
||||
make_backup(env.export_dir, "one.zip")
|
||||
entry = env.client.get("/api/v3/backup/list").get_json()["data"][0]
|
||||
assert entry["size"] == len(b"PK\x03\x04fake zip")
|
||||
assert entry["created_at"]
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_missing_file_is_a_400(self, env):
|
||||
response = env.client.post("/api/v3/backup/validate", data={},
|
||||
content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "No backup_file" in response.get_json()["message"]
|
||||
|
||||
def test_invalid_archive_is_a_400(self, env):
|
||||
response = env.client.post(
|
||||
"/api/v3/backup/validate",
|
||||
data={"backup_file": (io.BytesIO(b"not a zip"), "bad.zip")},
|
||||
content_type="multipart/form-data")
|
||||
assert response.status_code == 400
|
||||
assert "Invalid or corrupted" in response.get_json()["message"]
|
||||
|
||||
def test_validation_does_not_leave_temp_files_in_the_export_dir(self, env):
|
||||
env.client.post(
|
||||
"/api/v3/backup/validate",
|
||||
data={"backup_file": (io.BytesIO(b"not a zip"), "bad.zip")},
|
||||
content_type="multipart/form-data")
|
||||
assert list(env.export_dir.iterdir()) == []
|
||||
Reference in New Issue
Block a user