Compare commits

...

4 Commits

Author SHA1 Message Date
Stefan Agner
519d167917 Add guard for .. filename
Add guard for .. in filenames. Technically it would create a file named
like this and not traverse directories. But it doesn't seem useful so
block it as well. Also check . and .. in tests to validate they don't
cause issues.
2026-03-31 22:06:11 +02:00
Stefan Agner
56e5055ca6 Use unquoted FormData to test raw path traversal filenames
Use FormData(quote_fields=False) to send raw traversal filenames
to the server, ensuring the server-side sanitization is actually
exercised.
2026-03-31 20:46:34 +02:00
Stefan Agner
58b63718d2 Verify open files to match expected paths exactly 2026-03-31 20:24:54 +02:00
Stefan Agner
08abb19a1b Store received backup in temp backup dir only
Sanitize the suggested filename from multipart upload requests by
stripping directory components, preventing path traversal that could
write files outside the temp backup directory.
2026-03-31 18:25:57 +02:00
2 changed files with 70 additions and 1 deletions

View File

@@ -1957,7 +1957,10 @@ class CoreBackupReaderWriter(BackupReaderWriter):
suggested_filename: str,
) -> WrittenBackup:
"""Receive a backup."""
temp_file = Path(self.temp_backup_dir, suggested_filename)
safe_filename = Path(suggested_filename).name
if not safe_filename or safe_filename == "..":
safe_filename = "backup.tar"
temp_file = Path(self.temp_backup_dir, safe_filename)
async_add_executor_job = self._hass.async_add_executor_job
await async_add_executor_job(make_backup_dir, self.temp_backup_dir)

View File

@@ -23,6 +23,7 @@ from unittest.mock import (
patch,
)
from aiohttp import FormData
from freezegun.api import FrozenDateTimeFactory
import pytest
from securetar import SecureTarArchive, SecureTarFile
@@ -2013,6 +2014,71 @@ async def test_receive_backup(
assert unlink_mock.call_count == temp_file_unlink_call_count
@pytest.mark.parametrize(
("suggested_filename", "expected_filename"),
[
("backup.tar", "backup.tar"),
("../traversal.tar", "traversal.tar"),
("../../etc/passwd", "passwd"),
("subdir/backup.tar", "backup.tar"),
(".", "backup.tar"),
("..", "backup.tar"),
("../..", "backup.tar"),
],
)
async def test_receive_backup_path_traversal(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
suggested_filename: str,
expected_filename: str,
) -> None:
"""Test path traversal in suggested filename is prevented."""
await setup_backup_integration(hass)
# Make sure we wait for Platform.EVENT and Platform.SENSOR to be fully processed,
# to avoid interference with the Path.open patching below which is used to verify
# that the file is written to the expected location.
await hass.async_block_till_done(True)
client = await hass_client()
upload_data = "test"
open_mock = mock_open(read_data=upload_data.encode(encoding="utf-8"))
expected_path = Path(hass.config.path("tmp_backups"), expected_filename)
opened_paths: list[Path] = []
def track_open(self: Path, *args: Any, **kwargs: Any) -> Any:
opened_paths.append(self)
return open_mock(self, *args, **kwargs)
with (
patch("pathlib.Path.open", track_open),
patch("homeassistant.components.backup.manager.make_backup_dir"),
patch("shutil.move"),
patch(
"homeassistant.components.backup.manager.read_backup",
return_value=TEST_BACKUP_ABC123,
) as read_backup_mock,
patch("pathlib.Path.unlink"),
):
data = FormData(quote_fields=False)
data.add_field(
"file",
upload_data,
filename=suggested_filename,
content_type="application/octet-stream",
)
resp = await client.post(
"/api/backup/upload?agent_id=backup.local",
data=data,
)
await hass.async_block_till_done()
assert resp.status == 201
# Verify all file opens went to the expected safe path
assert opened_paths == [expected_path]
# read_backup is called with the temp_file path; verify it's sanitized
read_backup_mock.assert_called_once_with(expected_path)
async def test_receive_backup_busy_manager(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,