1
0
mirror of https://github.com/home-assistant/supervisor.git synced 2026-05-22 07:38:49 +01:00
Files
supervisor/tests/backups/test_backup_security.py
T
Mike Degatano ba8c49935b Refactor internal addon references to app/apps (#6717)
* 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>
2026-04-14 16:47:20 +02:00

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