mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-05-22 07:38:49 +01:00
ba8c49935b
* Rename addon→app in docstrings and comments Updates all docstrings and inline comments across supervisor/ and tests/ to use the new app/apps terminology. No runtime behaviour is changed by this commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Rename addon→app in code (variables, args, class names, functions) Renames all internal Python identifiers from addon/addons to app/apps: - Variable and argument names - Function and method names - Class names (Addon→App, AddonManager→AppManager, DockerAddon→DockerApp, all exception, check, and fixup classes, etc.) - String literals used as Python identifiers (pytest fixtures, parametrize param names, patch.object attribute strings, URL route match_info keys) External API contracts are preserved: JSON keys, error codes, discovery protocol fields, TypedDict/attr.s field names. Import module paths (supervisor/addons/) are also unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix partial backup/restore API to remap addons key to apps The external API accepts `addons` as the request body key (since ATTR_APPS = "addons"), but do_backup_partial and do_restore_partial now take an `apps` parameter after the rename. The **body expansion in both endpoints would pass `addons=...` causing a TypeError. Remap the key before expansion in both backup_partial and restore_partial: if ATTR_APPS in body: body["apps"] = body.pop(ATTR_APPS) Also adds test_restore_partial_with_addons_key to verify the restore path correctly receives apps= when addons is passed in the request body. This path had no existing test coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix merge error * Adjust AppLoggerAdapter to use app_name Co-authored-by: Stefan Agner <stefan@agner.ch> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Stefan Agner <stefan@agner.ch>
258 lines
9.0 KiB
Python
258 lines
9.0 KiB
Python
"""Security tests for backup tar extraction with tar filter."""
|
|
|
|
import io
|
|
from pathlib import Path
|
|
import tarfile
|
|
|
|
import pytest
|
|
from securetar import SecureTarFile
|
|
|
|
from supervisor.addons.addon import App
|
|
from supervisor.backups.backup import Backup
|
|
from supervisor.backups.const import BackupType
|
|
from supervisor.coresys import CoreSys
|
|
from supervisor.exceptions import BackupInvalidError
|
|
|
|
|
|
def _create_tar_gz(
|
|
path: Path,
|
|
members: list[tarfile.TarInfo],
|
|
file_data: dict[str, bytes] | None = None,
|
|
) -> None:
|
|
"""Create a tar.gz file with specified members."""
|
|
if file_data is None:
|
|
file_data = {}
|
|
with tarfile.open(path, "w:gz") as tar:
|
|
for info in members:
|
|
data = file_data.get(info.name)
|
|
if data is not None:
|
|
tar.addfile(info, io.BytesIO(data))
|
|
else:
|
|
tar.addfile(info)
|
|
|
|
|
|
def test_path_traversal_rejected(tmp_path: Path):
|
|
"""Test that path traversal in member names is rejected."""
|
|
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
|
traversal_info.size = 9
|
|
tar_path = tmp_path / "test.tar.gz"
|
|
_create_tar_gz(tar_path, [traversal_info], {"../../etc/passwd": b"malicious"})
|
|
|
|
dest = tmp_path / "out"
|
|
dest.mkdir()
|
|
with (
|
|
tarfile.open(tar_path, "r:gz") as tar,
|
|
pytest.raises(tarfile.OutsideDestinationError),
|
|
):
|
|
tar.extractall(path=dest, filter="tar")
|
|
|
|
|
|
def test_symlink_write_through_rejected(tmp_path: Path):
|
|
"""Test that writing through a symlink to outside destination is rejected.
|
|
|
|
The tar filter's realpath check follows already-extracted symlinks on disk,
|
|
catching write-through attacks even without explicit link target validation.
|
|
"""
|
|
# Symlink pointing outside, then a file entry writing through it
|
|
link_info = tarfile.TarInfo(name="escape")
|
|
link_info.type = tarfile.SYMTYPE
|
|
link_info.linkname = "../outside"
|
|
file_info = tarfile.TarInfo(name="escape/evil.py")
|
|
file_info.size = 9
|
|
tar_path = tmp_path / "test.tar.gz"
|
|
_create_tar_gz(
|
|
tar_path,
|
|
[link_info, file_info],
|
|
{"escape/evil.py": b"malicious"},
|
|
)
|
|
|
|
dest = tmp_path / "out"
|
|
dest.mkdir()
|
|
with (
|
|
tarfile.open(tar_path, "r:gz") as tar,
|
|
pytest.raises(tarfile.OutsideDestinationError),
|
|
):
|
|
tar.extractall(path=dest, filter="tar")
|
|
|
|
# The evil file must not exist outside the destination
|
|
assert not (tmp_path / "outside" / "evil.py").exists()
|
|
|
|
|
|
def test_absolute_name_stripped_and_extracted(tmp_path: Path):
|
|
"""Test that absolute member names have leading / stripped and extract safely."""
|
|
info = tarfile.TarInfo(name="/etc/test.conf")
|
|
info.size = 5
|
|
tar_path = tmp_path / "test.tar.gz"
|
|
_create_tar_gz(tar_path, [info], {"/etc/test.conf": b"hello"})
|
|
|
|
dest = tmp_path / "out"
|
|
dest.mkdir()
|
|
with tarfile.open(tar_path, "r:gz") as tar:
|
|
tar.extractall(path=dest, filter="tar")
|
|
|
|
# Extracted inside destination with leading / stripped
|
|
assert (dest / "etc" / "test.conf").read_text() == "hello"
|
|
|
|
|
|
def test_valid_backup_with_internal_symlinks(tmp_path: Path):
|
|
"""Test that valid backups with internal relative symlinks extract correctly."""
|
|
dir_info = tarfile.TarInfo(name="subdir")
|
|
dir_info.type = tarfile.DIRTYPE
|
|
dir_info.mode = 0o755
|
|
|
|
file_info = tarfile.TarInfo(name="subdir/config.yaml")
|
|
file_info.size = 11
|
|
|
|
link_info = tarfile.TarInfo(name="config_link")
|
|
link_info.type = tarfile.SYMTYPE
|
|
link_info.linkname = "subdir/config.yaml"
|
|
|
|
tar_path = tmp_path / "test.tar.gz"
|
|
_create_tar_gz(
|
|
tar_path,
|
|
[dir_info, file_info, link_info],
|
|
{"subdir/config.yaml": b"key: value\n"},
|
|
)
|
|
|
|
dest = tmp_path / "out"
|
|
dest.mkdir()
|
|
with tarfile.open(tar_path, "r:gz") as tar:
|
|
tar.extractall(path=dest, filter="tar")
|
|
|
|
assert (dest / "subdir" / "config.yaml").read_text() == "key: value\n"
|
|
assert (dest / "config_link").is_symlink()
|
|
assert (dest / "config_link").read_text() == "key: value\n"
|
|
|
|
|
|
def test_uid_gid_preserved(tmp_path: Path):
|
|
"""Test that tar filter preserves file ownership."""
|
|
info = tarfile.TarInfo(name="owned_file.txt")
|
|
info.size = 5
|
|
info.uid = 1000
|
|
info.gid = 1000
|
|
tar_path = tmp_path / "test.tar.gz"
|
|
_create_tar_gz(tar_path, [info], {"owned_file.txt": b"hello"})
|
|
|
|
dest = tmp_path / "out"
|
|
dest.mkdir()
|
|
with tarfile.open(tar_path, "r:gz") as tar:
|
|
# Extract member via filter only (don't actually extract, just check
|
|
# the filter preserves uid/gid)
|
|
for member in tar:
|
|
filtered = tarfile.tar_filter(member, str(dest))
|
|
assert filtered.uid == 1000
|
|
assert filtered.gid == 1000
|
|
|
|
|
|
async def test_backup_open_rejects_path_traversal(coresys: CoreSys, tmp_path: Path):
|
|
"""Test that Backup.open() raises BackupInvalidError for path traversal."""
|
|
tar_path = tmp_path / "malicious.tar"
|
|
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
|
traversal_info.size = 9
|
|
with tarfile.open(tar_path, "w:") as tar:
|
|
tar.addfile(traversal_info, io.BytesIO(b"malicious"))
|
|
|
|
backup = Backup(coresys, tar_path, "test", None)
|
|
with pytest.raises(BackupInvalidError):
|
|
async with backup.open(None):
|
|
pass
|
|
|
|
|
|
async def test_homeassistant_restore_rejects_path_traversal(
|
|
coresys: CoreSys, tmp_supervisor_data: Path
|
|
):
|
|
"""Test that Home Assistant restore raises BackupInvalidError for path traversal."""
|
|
tar_path = tmp_supervisor_data / "homeassistant.tar.gz"
|
|
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
|
traversal_info.size = 9
|
|
_create_tar_gz(tar_path, [traversal_info], {"../../etc/passwd": b"malicious"})
|
|
|
|
tar_file = SecureTarFile(tar_path, gzip=True)
|
|
with pytest.raises(BackupInvalidError):
|
|
await coresys.homeassistant.restore(tar_file)
|
|
|
|
|
|
async def test_app_restore_rejects_path_traversal(
|
|
coresys: CoreSys, install_app_ssh: App, tmp_supervisor_data: Path
|
|
):
|
|
"""Test that app restore raises BackupInvalidError for path traversal."""
|
|
tar_path = tmp_supervisor_data / "addon.tar.gz"
|
|
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
|
traversal_info.size = 9
|
|
_create_tar_gz(tar_path, [traversal_info], {"../../etc/passwd": b"malicious"})
|
|
|
|
tar_file = SecureTarFile(tar_path, gzip=True)
|
|
with pytest.raises(BackupInvalidError):
|
|
await install_app_ssh.restore(tar_file)
|
|
|
|
|
|
async def test_app_restore_rejects_symlink_escape(
|
|
coresys: CoreSys, install_app_ssh: App, tmp_supervisor_data: Path
|
|
):
|
|
"""Test that app restore raises BackupInvalidError for symlink escape."""
|
|
link_info = tarfile.TarInfo(name="escape")
|
|
link_info.type = tarfile.SYMTYPE
|
|
link_info.linkname = "../outside"
|
|
file_info = tarfile.TarInfo(name="escape/evil.py")
|
|
file_info.size = 9
|
|
|
|
tar_path = tmp_supervisor_data / "addon.tar.gz"
|
|
_create_tar_gz(
|
|
tar_path,
|
|
[link_info, file_info],
|
|
{"escape/evil.py": b"malicious"},
|
|
)
|
|
|
|
tar_file = SecureTarFile(tar_path, gzip=True)
|
|
with pytest.raises(BackupInvalidError):
|
|
await install_app_ssh.restore(tar_file)
|
|
|
|
|
|
async def test_folder_restore_rejects_path_traversal(
|
|
coresys: CoreSys, tmp_supervisor_data: Path
|
|
):
|
|
"""Test that folder restore rejects path traversal in backup tar."""
|
|
traversal_info = tarfile.TarInfo(name="../../etc/passwd")
|
|
traversal_info.size = 9
|
|
|
|
# Create backup with a malicious share folder tar inside
|
|
backup_tar_path = tmp_supervisor_data / "backup.tar"
|
|
with tarfile.open(backup_tar_path, "w:") as outer_tar:
|
|
share_tar_path = tmp_supervisor_data / "share.tar.gz"
|
|
_create_tar_gz(
|
|
share_tar_path, [traversal_info], {"../../etc/passwd": b"malicious"}
|
|
)
|
|
outer_tar.add(share_tar_path, arcname="./share.tar.gz")
|
|
|
|
backup = Backup(coresys, backup_tar_path, "test", None)
|
|
backup.new("test", "2025-01-01", BackupType.PARTIAL, compressed=True)
|
|
async with backup.open(None):
|
|
assert await backup.restore_folders(["share"]) is False
|
|
|
|
|
|
async def test_folder_restore_rejects_symlink_escape(
|
|
coresys: CoreSys, tmp_supervisor_data: Path
|
|
):
|
|
"""Test that folder restore rejects symlink escape in backup tar."""
|
|
link_info = tarfile.TarInfo(name="escape")
|
|
link_info.type = tarfile.SYMTYPE
|
|
link_info.linkname = "../outside"
|
|
file_info = tarfile.TarInfo(name="escape/evil.py")
|
|
file_info.size = 9
|
|
|
|
# Create backup with a malicious share folder tar inside
|
|
backup_tar_path = tmp_supervisor_data / "backup.tar"
|
|
with tarfile.open(backup_tar_path, "w:") as outer_tar:
|
|
share_tar_path = tmp_supervisor_data / "share.tar.gz"
|
|
_create_tar_gz(
|
|
share_tar_path,
|
|
[link_info, file_info],
|
|
{"escape/evil.py": b"malicious"},
|
|
)
|
|
outer_tar.add(share_tar_path, arcname="./share.tar.gz")
|
|
|
|
backup = Backup(coresys, backup_tar_path, "test", None)
|
|
backup.new("test", "2025-01-01", BackupType.PARTIAL, compressed=True)
|
|
async with backup.open(None):
|
|
assert await backup.restore_folders(["share"]) is False
|