From 780b6efcd9c44ceb0250c10f74f8e91e6426b154 Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:06:37 +0000 Subject: [PATCH 1/2] Fix path traversal in /api/backup/restore Validate the restore filename stays within backups/ and refuse tar members (and symlinks) that escape the extraction root (CVE-2007-4559 class), using tarfile's data filter. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 41bfe56..c8a795e 100644 --- a/server.py +++ b/server.py @@ -453,13 +453,38 @@ def create_backup(): append_audit({"action": "backup_created", "file": backup_file.name}) return {"status": "ok", "file": backup_file.name, "size": backup_file.stat().st_size} +def _resolve_backup_file(name: str) -> Path: + """Resolve a restore request to a real .tar.gz inside backups/, rejecting traversal.""" + if not name or name != Path(name).name or not name.endswith(".tar.gz"): + raise HTTPException(400, "Invalid backup file name") + backup_dir = (BASE_DIR / "backups").resolve() + candidate = (backup_dir / name).resolve() + if candidate.parent != backup_dir: + raise HTTPException(400, "Invalid backup file name") + return candidate + + +def _safe_extractall(tar: tarfile.TarFile, dest: Path): + """Extract a tar archive, refusing members that would escape dest (CVE-2007-4559).""" + dest = dest.resolve() + for member in tar.getmembers(): + target = (dest / member.name).resolve() + if target != dest and dest not in target.parents: + raise HTTPException(400, f"Unsafe path in archive: {member.name}") + if member.issym() or member.islnk(): + link_target = (target.parent / member.linkname).resolve() + if link_target != dest and dest not in link_target.parents: + raise HTTPException(400, f"Unsafe link in archive: {member.name}") + tar.extractall(path=dest, filter="data") + + @app.post("/api/backup/restore") def restore_backup(data: BackupRestoreRequest): - backup_file = BASE_DIR / "backups" / data.file + backup_file = _resolve_backup_file(data.file) if not backup_file.exists(): raise HTTPException(404, "Backup file not found") with tarfile.open(backup_file, "r:gz") as tar: - tar.extractall(path=BASE_DIR) + _safe_extractall(tar, BASE_DIR) append_audit({"action": "backup_restored", "file": data.file}) return {"status": "restored"} From 9ba87ddc68ca22d9254f4de746f6c1ee0349b0fa Mon Sep 17 00:00:00 2001 From: zumayaaustin Date: Wed, 8 Jul 2026 23:09:29 +0000 Subject: [PATCH 2/2] Resolve backup filename from directory listing (satisfy CodeQL path-injection) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- server.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index c8a795e..076f923 100644 --- a/server.py +++ b/server.py @@ -454,14 +454,18 @@ def create_backup(): return {"status": "ok", "file": backup_file.name, "size": backup_file.stat().st_size} def _resolve_backup_file(name: str) -> Path: - """Resolve a restore request to a real .tar.gz inside backups/, rejecting traversal.""" + """Resolve a restore request to a real .tar.gz inside backups/, rejecting traversal. + + The returned path is taken from the directory listing (never built from the raw + request value), so a caller can only ever select an existing backup file. + """ if not name or name != Path(name).name or not name.endswith(".tar.gz"): raise HTTPException(400, "Invalid backup file name") backup_dir = (BASE_DIR / "backups").resolve() - candidate = (backup_dir / name).resolve() - if candidate.parent != backup_dir: - raise HTTPException(400, "Invalid backup file name") - return candidate + for candidate in backup_dir.glob("*.tar.gz"): + if candidate.name == name: + return candidate + raise HTTPException(404, "Backup file not found") def _safe_extractall(tar: tarfile.TarFile, dest: Path):