Merge pull request #11 from zumayaaustin-creator/devin/1783551997-fix-backup-restore-path-traversal
* 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> * Resolve backup filename from directory listing (satisfy CodeQL path-injection) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: zumayaaustin <zumayaaustin@gmail.com> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
2d80ab7348
33
server.py
33
server.py
|
|
@ -574,13 +574,42 @@ 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.
|
||||
|
||||
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()
|
||||
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):
|
||||
"""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"}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue