Compare commits

...

1 Commits

Author SHA1 Message Date
Stefan Agner bbbcc0eba4 Harden backup tar extraction with Python tar_filter
Use Python's built-in tarfile tar filter instead of the no-op
fully_trusted filter combined with securetar.secure_path. The tar
filter validates linkname targets in addition to member names,
preventing extraction through a symlink whose linkname points outside
the destination directory.

Restore now aborts on the first rejected member instead of silently
skipping it, surfacing tampered backups via .HA_RESTORE_RESULT rather
than producing a partial restore.

Mirrors home-assistant/supervisor#6559.
2026-05-26 12:48:13 +02:00
2 changed files with 57 additions and 26 deletions
+2 -4
View File
@@ -92,8 +92,7 @@ def _extract_backup(
):
ostf.tar.extractall(
path=Path(tempdir, "extracted"),
members=securetar.secure_path(ostf.tar),
filter="fully_trusted",
filter="tar",
)
backup_meta_file = Path(tempdir, "extracted", "backup.json")
backup_meta = json.loads(backup_meta_file.read_text(encoding="utf8"))
@@ -119,8 +118,7 @@ def _extract_backup(
) as istf:
istf.extractall(
path=Path(tempdir, "homeassistant"),
members=securetar.secure_path(istf),
filter="fully_trusted",
filter="tar",
)
if restore_content.restore_homeassistant:
keep = list(KEEP_BACKUPS)
+55 -22
View File
@@ -1,5 +1,6 @@
"""Test methods in backup_restore."""
from io import BytesIO
import json
from pathlib import Path
import tarfile
@@ -386,8 +387,8 @@ def test_restore_backup(
}
def test_restore_backup_filter_files(tmp_path: Path) -> None:
"""Test filtering dangerous files when restoring a backup."""
def test_restore_backup_rejects_unsafe_files(tmp_path: Path) -> None:
"""Test that a backup with unsafe paths is rejected."""
backup_file_path = tmp_path / "backups" / "test.tar"
backup_file_path.parent.mkdir()
get_fixture_path(
@@ -410,7 +411,55 @@ def test_restore_backup_filter_files(tmp_path: Path) -> None:
"data/home-assistant_v2.db-wal",
}
real_extractone = tarfile.TarFile._extract_one
with (
mock.patch(
"homeassistant.backup_restore.restore_backup_file_content",
return_value=backup_restore.RestoreBackupFileContent(
backup_file_path=backup_file_path,
password=None,
remove_after_restore=False,
restore_database=True,
restore_homeassistant=True,
),
),
pytest.raises(tarfile.FilterError),
):
backup_restore.restore_backup(tmp_path.as_posix())
result = restore_result_file_content(tmp_path)
assert result is not None
assert result["success"] is False
assert result["error_type"] in {"AbsolutePathError", "OutsideDestinationError"}
def test_restore_backup_rejects_absolute_symlink(tmp_path: Path) -> None:
"""Test rejection of a symlink whose linkname escapes the destination.
A SYMTYPE entry followed by a regular file whose name traverses the
symlink would otherwise land attacker-controlled bytes outside the
extraction directory. The tar filter resolves the path after the
symlink and rejects the entry.
"""
backup_file_path = tmp_path / "backups" / "test.tar"
backup_file_path.parent.mkdir()
with tarfile.open(backup_file_path, "w") as tar:
backup_json = json.dumps(
{"homeassistant": {"version": "0.0.0"}, "compressed": False}
).encode()
info = tarfile.TarInfo(name="./backup.json")
info.size = len(backup_json)
tar.addfile(info, BytesIO(backup_json))
symlink = tarfile.TarInfo(name="pwn")
symlink.type = tarfile.SYMTYPE
symlink.linkname = "/tmp" # noqa: S108
tar.addfile(symlink)
payload = b"pwned"
evil = tarfile.TarInfo(name="pwn/ha_escape_target")
evil.size = len(payload)
tar.addfile(evil, BytesIO(payload))
with (
mock.patch(
@@ -423,27 +472,11 @@ def test_restore_backup_filter_files(tmp_path: Path) -> None:
restore_homeassistant=True,
),
),
mock.patch(
"tarfile.TarFile._extract_one", autospec=True, wraps=real_extractone
) as extractone_mock,
pytest.raises(tarfile.FilterError),
):
assert backup_restore.restore_backup(tmp_path.as_posix()) is True
backup_restore.restore_backup(tmp_path.as_posix())
# Check the unsafe files are not extracted, and that the safe files are extracted
extracted_files = {call.args[1].name for call in extractone_mock.mock_calls}
assert extracted_files == {
"./backup.json", # From the outer tar
"homeassistant.tar.gz", # From the outer tar
".",
"data",
"data/home-assistant_v2.db",
"data/home-assistant_v2.db-wal",
}
assert restore_result_file_content(tmp_path) == {
"error": None,
"error_type": None,
"success": True,
}
assert not Path("/tmp/ha_escape_target").exists() # noqa: S108
@pytest.mark.parametrize(("remove_after_restore"), [True, False])