diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3451195f3cd..0e0d42149fc 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -32,6 +32,7 @@ from homeassistant.core import ( HassJob, HomeAssistant, ServiceCall, + async_get_hass, callback, ) from homeassistant.exceptions import HomeAssistantError @@ -149,9 +150,22 @@ SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" +def valid_addon(value: Any) -> str: + """Validate value is a valid addon slug.""" + value = cv.slug(value) + + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + + if hass and (addons := get_addons_info(hass)) is not None and value not in addons: + raise vol.Invalid("Not a valid add-on slug") + return value + + SCHEMA_NO_DATA = vol.Schema({}) -SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): cv.string}) +SCHEMA_ADDON = vol.Schema({vol.Required(ATTR_ADDON): valid_addon}) SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend( {vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)} @@ -174,7 +188,7 @@ SCHEMA_BACKUP_PARTIAL = SCHEMA_BACKUP_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]), } ) @@ -189,7 +203,7 @@ SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend( { vol.Optional(ATTR_HOMEASSISTANT): cv.boolean, vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.slug]), } ) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index e4a0dd0f77e..020a4365ec6 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -8,6 +8,7 @@ import os from typing import Any import aiohttp +from yarl import URL from homeassistant.components.http import ( CONF_SERVER_HOST, @@ -530,6 +531,11 @@ class HassIO: This method is a coroutine. """ + url = f"http://{self._ip}{command}" + if url != str(URL(url)): + _LOGGER.error("Invalid request %s", command) + raise HassioAPIError() + try: request = await self.websession.request( method, diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index e980bf214a0..5a89ea8335a 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -413,3 +413,10 @@ async def test_api_reboot_host( assert await handler.async_reboot_host(hass) == {} assert aioclient_mock.call_count == 1 + + +async def test_send_command_invalid_command(hass: HomeAssistant, hassio_stubs) -> None: + """Test send command fails when command is invalid.""" + hassio: HassIO = hass.data["hassio"] + with pytest.raises(HassioAPIError): + await hassio.send_command("/test/../bad") diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index b394d439654..4b10c58036e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import patch import pytest +from voluptuous import Invalid from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import frontend @@ -100,29 +101,29 @@ def mock_all(aioclient_mock, request, os_info): "version_latest": "1.0.0", "version": "1.0.0", "auto_update": True, + "addons": [ + { + "name": "test", + "slug": "test", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + { + "name": "test2", + "slug": "test2", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + ], }, - "addons": [ - { - "name": "test", - "slug": "test", - "installed": True, - "update_available": False, - "version": "1.0.0", - "version_latest": "1.0.0", - "repository": "core", - "url": "https://github.com/home-assistant/addons/test", - }, - { - "name": "test2", - "slug": "test2", - "installed": True, - "update_available": False, - "version": "1.0.0", - "version_latest": "1.0.0", - "repository": "core", - "url": "https://github.com", - }, - ], }, ) aioclient_mock.get( @@ -243,7 +244,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -288,7 +289,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -307,7 +308,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -324,7 +325,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -404,7 +405,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -421,7 +422,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -441,7 +442,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -486,13 +487,17 @@ async def test_service_register(hassio_env, hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2021-11-13 11:48:00") async def test_service_calls( - hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, ) -> None: """Call service and check the API calls behind that.""" - assert await async_setup_component(hass, "hassio", {}) + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() aioclient_mock.post("http://127.0.0.1/addons/test/start", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/addons/test/stop", json={"result": "ok"}) @@ -519,14 +524,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 26 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 12 + assert aioclient_mock.call_count == 28 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -541,7 +546,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 14 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "homeassistant": True, @@ -566,7 +571,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 16 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -584,7 +589,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 17 + assert aioclient_mock.call_count == 33 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -599,13 +604,35 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, } +async def test_invalid_service_calls( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Call service with invalid input and check that it raises.""" + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + with pytest.raises(Invalid): + await hass.services.async_call( + "hassio", "addon_start", {"addon": "does_not_exist"} + ) + with pytest.raises(Invalid): + await hass.services.async_call( + "hassio", "addon_stdin", {"addon": "does_not_exist", "input": "test"} + ) + + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: @@ -889,7 +916,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 18 + assert aioclient_mock.call_count == 22 assert len(mock_setup_entry.mock_calls) == 1