mirror of
				https://github.com/esphome/esphome.git
				synced 2025-10-30 22:53:59 +00:00 
			
		
		
		
	[dashboard] fix migration to Path (#11342)
Co-authored-by: J. Nick Koston <nick@home-assistant.io>
This commit is contained in:
		| @@ -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