mirror of
https://github.com/esphome/esphome.git
synced 2025-10-20 10:43:48 +01:00
[dashboard] fix migration to Path (#11342)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
@@ -1058,7 +1058,8 @@ class DownloadBinaryRequestHandler(BaseHandler):
|
||||
"download",
|
||||
f"{storage_json.name}-{file_name}",
|
||||
)
|
||||
path = storage_json.firmware_bin_path.with_name(file_name)
|
||||
|
||||
path = storage_json.firmware_bin_path.parent.joinpath(file_name)
|
||||
|
||||
if not path.is_file():
|
||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||
|
@@ -35,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport
|
||||
from .common import get_fixture_path
|
||||
|
||||
|
||||
def get_build_path(base_path: Path, device_name: str) -> Path:
|
||||
"""Get the build directory path for a device.
|
||||
|
||||
This is a test helper that constructs the standard ESPHome build directory
|
||||
structure. Note: This helper does NOT perform path traversal sanitization
|
||||
because it's only used in tests where we control the inputs. The actual
|
||||
web_server.py code handles sanitization in DownloadBinaryRequestHandler.get()
|
||||
via file_name.replace("..", "").lstrip("/").
|
||||
|
||||
Args:
|
||||
base_path: The base temporary path (typically tmp_path from pytest)
|
||||
device_name: The name of the device (should not contain path separators
|
||||
in production use, but tests may use it for specific scenarios)
|
||||
|
||||
Returns:
|
||||
Path to the build directory (.esphome/build/device_name)
|
||||
"""
|
||||
return base_path / ".esphome" / "build" / device_name
|
||||
|
||||
|
||||
class DashboardTestHelper:
|
||||
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
|
||||
self.io_loop = io_loop
|
||||
@@ -417,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback(
|
||||
assert response.body == b"bootloader content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_subdirectory_file(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test the DownloadBinaryRequestHandler.get with file in subdirectory (nRF52 case).
|
||||
|
||||
This is a regression test for issue #11343 where the Path migration broke
|
||||
downloads for nRF52 firmware files in subdirectories like 'zephyr/zephyr.uf2'.
|
||||
|
||||
The issue was that with_name() doesn't accept path separators:
|
||||
- Before: path = storage_json.firmware_bin_path.with_name(file_name)
|
||||
ValueError: Invalid name 'zephyr/zephyr.uf2'
|
||||
- After: path = storage_json.firmware_bin_path.parent.joinpath(file_name)
|
||||
Works correctly with subdirectory paths
|
||||
"""
|
||||
# Create a fake nRF52 build structure with firmware in subdirectory
|
||||
build_dir = get_build_path(tmp_path, "nrf52-device")
|
||||
zephyr_dir = build_dir / "zephyr"
|
||||
zephyr_dir.mkdir(parents=True)
|
||||
|
||||
# Create the main firmware binary (would be in build root)
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"main firmware")
|
||||
|
||||
# Create the UF2 file in zephyr subdirectory (nRF52 specific)
|
||||
uf2_file = zephyr_dir / "zephyr.uf2"
|
||||
uf2_file.write_bytes(b"nRF52 UF2 firmware content")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "nrf52-device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Request the UF2 file with subdirectory path
|
||||
response = await dashboard.fetch(
|
||||
"/download.bin?configuration=nrf52-device.yaml&file=zephyr/zephyr.uf2",
|
||||
method="GET",
|
||||
)
|
||||
assert response.code == 200
|
||||
assert response.body == b"nRF52 UF2 firmware content"
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
# Download name should be device-name + full file path
|
||||
assert "nrf52-device-zephyr/zephyr.uf2" in response.headers["Content-Disposition"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_subdirectory_file_url_encoded(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test the DownloadBinaryRequestHandler.get with URL-encoded subdirectory path.
|
||||
|
||||
Verifies that URL-encoded paths (e.g., zephyr%2Fzephyr.uf2) are correctly
|
||||
decoded and handled, and that custom download names work with subdirectories.
|
||||
"""
|
||||
# Create a fake build structure with firmware in subdirectory
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
zephyr_dir = build_dir / "zephyr"
|
||||
zephyr_dir.mkdir(parents=True)
|
||||
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"content")
|
||||
|
||||
uf2_file = zephyr_dir / "zephyr.uf2"
|
||||
uf2_file.write_bytes(b"content")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Request with URL-encoded path and custom download name
|
||||
response = await dashboard.fetch(
|
||||
"/download.bin?configuration=test.yaml&file=zephyr%2Fzephyr.uf2&download=custom_name.bin",
|
||||
method="GET",
|
||||
)
|
||||
assert response.code == 200
|
||||
assert "custom_name.bin" in response.headers["Content-Disposition"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
@pytest.mark.parametrize(
|
||||
"attack_path",
|
||||
[
|
||||
pytest.param("../../../secrets.yaml", id="basic_traversal"),
|
||||
pytest.param("..%2F..%2F..%2Fsecrets.yaml", id="url_encoded"),
|
||||
pytest.param("zephyr/../../../secrets.yaml", id="traversal_with_prefix"),
|
||||
pytest.param("/etc/passwd", id="absolute_path"),
|
||||
pytest.param("//etc/passwd", id="double_slash_absolute"),
|
||||
pytest.param("....//secrets.yaml", id="multiple_dots"),
|
||||
],
|
||||
)
|
||||
async def test_download_binary_handler_path_traversal_protection(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
attack_path: str,
|
||||
) -> None:
|
||||
"""Test that DownloadBinaryRequestHandler prevents path traversal attacks.
|
||||
|
||||
Verifies that attempts to use '..' in file paths are sanitized to prevent
|
||||
accessing files outside the build directory. Tests multiple attack vectors.
|
||||
"""
|
||||
# Create build structure
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
build_dir.mkdir(parents=True)
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"firmware content")
|
||||
|
||||
# Create a sensitive file outside the build directory that should NOT be accessible
|
||||
sensitive_file = tmp_path / "secrets.yaml"
|
||||
sensitive_file.write_bytes(b"secret: my_secret_password")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
# Attempt path traversal attack - should be blocked
|
||||
with pytest.raises(HTTPClientError) as exc_info:
|
||||
await dashboard.fetch(
|
||||
f"/download.bin?configuration=test.yaml&file={attack_path}",
|
||||
method="GET",
|
||||
)
|
||||
# Should get 404 (file not found after sanitization) or 500 (idedata fails)
|
||||
assert exc_info.value.code in (404, 500)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.usefixtures("mock_ext_storage_path")
|
||||
async def test_download_binary_handler_multiple_subdirectory_levels(
|
||||
dashboard: DashboardTestHelper,
|
||||
tmp_path: Path,
|
||||
mock_storage_json: MagicMock,
|
||||
) -> None:
|
||||
"""Test downloading files from multiple subdirectory levels.
|
||||
|
||||
Verifies that joinpath correctly handles multi-level paths like 'build/output/firmware.bin'.
|
||||
"""
|
||||
# Create nested directory structure
|
||||
build_dir = get_build_path(tmp_path, "test")
|
||||
nested_dir = build_dir / "build" / "output"
|
||||
nested_dir.mkdir(parents=True)
|
||||
|
||||
firmware_file = build_dir / "firmware.bin"
|
||||
firmware_file.write_bytes(b"main")
|
||||
|
||||
nested_file = nested_dir / "firmware.bin"
|
||||
nested_file.write_bytes(b"nested firmware content")
|
||||
|
||||
# Mock storage JSON
|
||||
mock_storage = Mock()
|
||||
mock_storage.name = "test_device"
|
||||
mock_storage.firmware_bin_path = firmware_file
|
||||
mock_storage_json.load.return_value = mock_storage
|
||||
|
||||
response = await dashboard.fetch(
|
||||
"/download.bin?configuration=test.yaml&file=build/output/firmware.bin",
|
||||
method="GET",
|
||||
)
|
||||
assert response.code == 200
|
||||
assert response.body == b"nested firmware content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_request_handler_post_invalid_file(
|
||||
dashboard: DashboardTestHelper,
|
||||
|
Reference in New Issue
Block a user