mirror of
https://github.com/esphome/esphome.git
synced 2025-10-20 18:53:47 +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",
|
"download",
|
||||||
f"{storage_json.name}-{file_name}",
|
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():
|
if not path.is_file():
|
||||||
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
args = ["esphome", "idedata", settings.rel_path(configuration)]
|
||||||
|
@@ -35,6 +35,26 @@ from esphome.zeroconf import DiscoveredImport
|
|||||||
from .common import get_fixture_path
|
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:
|
class DashboardTestHelper:
|
||||||
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
|
def __init__(self, io_loop: IOLoop, client: AsyncHTTPClient, port: int) -> None:
|
||||||
self.io_loop = io_loop
|
self.io_loop = io_loop
|
||||||
@@ -417,6 +437,180 @@ async def test_download_binary_handler_idedata_fallback(
|
|||||||
assert response.body == b"bootloader content"
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_edit_request_handler_post_invalid_file(
|
async def test_edit_request_handler_post_invalid_file(
|
||||||
dashboard: DashboardTestHelper,
|
dashboard: DashboardTestHelper,
|
||||||
|
Reference in New Issue
Block a user