mirror of
https://github.com/home-assistant/supervisor.git
synced 2026-08-13 08:22:36 +01:00
* Enter STOPPING state before Supervisor restart API response The Supervisor accepts new API requests in the window after responding to /supervisor/restart: restart() schedules Core.stop() as a task and returns immediately, so the state only changes to stopping after the restart response has been sent. A request accepted in that window keeps running while the Supervisor shuts down, and when stop() tears down the API server after the 10 second stage 1 timeout the connection is dropped without a response - the client sees EOF instead of an error. This is what made the CI restore step flaky (see #7084 for the CI-side fix): the restore request landed on the old, dying instance. Make restart() transition to STOPPING before returning, via a new Core.begin_stop() which contains the transition part previously at the start of Core.stop(). The system validation middleware already rejects requests outside of STARTUP/RUNNING/FREEZE, so any request arriving after the restart response now gets a clear "System is not ready" error instead of possibly being accepted and killed. Since the state can now already be STOPPING when stop() runs, its re-entry guard is changed from a state check to an explicit flag. Supervisor.update() schedules the same stop task but is left unchanged: entering STOPPING before update() returns would suppress the final job progress event to Home Assistant (WebSocket messages are dropped in CLOSING_STATES), regressing update progress reporting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Allow stop() retry when begin_stop() fails Address review feedback: stop() set _stop_initiated before awaiting begin_stop(), so an exception or cancellation during the state transition (e.g. a failing config write in _update_last_boot) would leave the flag set and turn every later stop() call into a no-op, with no way to retry the teardown. Reset the flag and re-raise when begin_stop() fails. Nothing has been torn down at that point, so a retry is safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Use a stopping_complete event instead of begin_stop() Address review feedback questioning the extra infrastructure: instead of splitting the state transition out of stop() (begin_stop()) and guarding re-entry with an explicit flag, have stop() take an optional stopping_complete event which is set once the STOPPING state is entered, following the same pattern as backup/restore's validation_complete. Core.stop() keeps its original structure and re-entry semantics, and restart() waits for the event before returning. Should the stop task fail ahead of the state transition (only possible through a failing config write in _update_last_boot()), the event is never set and the restart request runs into the client timeout - an accepted trade-off to keep restart() simple. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""Test API security layer."""
|
|
|
|
import asyncio
|
|
from http import HTTPStatus
|
|
from unittest.mock import patch
|
|
|
|
from aiohttp import web
|
|
from aiohttp.test_utils import TestClient
|
|
import pytest
|
|
import urllib3
|
|
|
|
from supervisor.api import RestAPI
|
|
from supervisor.apps.app import App
|
|
from supervisor.const import ROLE_ALL, CoreState
|
|
from supervisor.coresys import CoreSys
|
|
|
|
# pylint: disable=redefined-outer-name
|
|
|
|
|
|
async def mock_handler(request):
|
|
"""Return OK."""
|
|
return web.Response(text="OK")
|
|
|
|
|
|
@pytest.fixture
|
|
async def api_system(aiohttp_client, coresys: CoreSys) -> TestClient:
|
|
"""Fixture for RestAPI client."""
|
|
api = RestAPI(coresys)
|
|
api.webapp = web.Application()
|
|
with patch("supervisor.docker.supervisor.os") as os:
|
|
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
|
|
await api.load()
|
|
|
|
api.webapp.middlewares.append(api.security.block_bad_requests)
|
|
api.webapp.middlewares.append(api.security.system_validation)
|
|
api.webapp.router.add_get("/{all:.*}", mock_handler)
|
|
|
|
return await aiohttp_client(api.webapp)
|
|
|
|
|
|
@pytest.fixture
|
|
async def api_token_validation(aiohttp_client, coresys: CoreSys) -> TestClient:
|
|
"""Fixture for RestAPI client with token validation middleware."""
|
|
api = RestAPI(coresys)
|
|
api.webapp = web.Application()
|
|
with patch("supervisor.docker.supervisor.os") as os:
|
|
os.environ = {"SUPERVISOR_NAME": "hassio_supervisor"}
|
|
await api.start()
|
|
|
|
api.webapp.middlewares.append(api.security.token_validation)
|
|
api.webapp.router.add_get("/{all:.*}", mock_handler)
|
|
api.webapp.router.add_post("/{all:.*}", mock_handler)
|
|
api.webapp.router.add_delete("/{all:.*}", mock_handler)
|
|
|
|
return await aiohttp_client(api.webapp)
|
|
|
|
|
|
@pytest.fixture(
|
|
name="api_token_validation_with_prefix",
|
|
params=[pytest.param("", id="v1"), pytest.param("/v2", id="v2")],
|
|
)
|
|
async def fixture_api_token_validation_with_prefix(
|
|
request: pytest.FixtureRequest,
|
|
api_token_validation: TestClient,
|
|
) -> tuple[TestClient, str]:
|
|
"""Provide (client, path_prefix) for token_validation on both API versions.
|
|
|
|
Mirrors api_client_with_prefix, but keeps the real token_validation
|
|
middleware (which api_client/api_client_v2 replace with a stub) so security
|
|
checks like the blacklist are actually exercised on the v1 and v2 paths.
|
|
"""
|
|
return api_token_validation, request.param
|
|
|
|
|
|
@pytest.fixture(name="plugin_tokens")
|
|
async def fixture_plugin_tokens(coresys: CoreSys) -> None:
|
|
"""Mock plugin tokens used in middleware."""
|
|
# pylint: disable=protected-access
|
|
coresys.plugins.cli._data["access_token"] = "c_123456"
|
|
coresys.plugins.observer._data["access_token"] = "o_123456"
|
|
# pylint: enable=protected-access
|
|
|
|
|
|
async def test_api_security_system_initialize(api_system: TestClient, coresys: CoreSys):
|
|
"""Test security."""
|
|
await coresys.core.set_state(CoreState.INITIALIZE)
|
|
|
|
resp = await api_system.get("/supervisor/ping")
|
|
result = await resp.json()
|
|
assert resp.status == 400
|
|
assert result["result"] == "error"
|
|
|
|
|
|
async def test_api_security_system_setup(api_system: TestClient, coresys: CoreSys):
|
|
"""Test security."""
|
|
await coresys.core.set_state(CoreState.SETUP)
|
|
|
|
resp = await api_system.get("/supervisor/ping")
|
|
result = await resp.json()
|
|
assert resp.status == 400
|
|
assert result["result"] == "error"
|
|
|
|
|
|
async def test_api_security_system_running(api_system: TestClient, coresys: CoreSys):
|
|
"""Test security."""
|
|
await coresys.core.set_state(CoreState.RUNNING)
|
|
|
|
resp = await api_system.get("/supervisor/ping")
|
|
assert resp.status == 200
|
|
|
|
|
|
async def test_api_security_system_startup(api_system: TestClient, coresys: CoreSys):
|
|
"""Test security."""
|
|
await coresys.core.set_state(CoreState.STARTUP)
|
|
|
|
resp = await api_system.get("/supervisor/ping")
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("request_path", "request_params", "fail_on_query_string"),
|
|
[
|
|
("/proc/self/environ", {}, False),
|
|
("/", {"test": "/test/../../api"}, True),
|
|
("/", {"test": "test/../../api"}, True),
|
|
("/", {"test": "/test/%2E%2E%2f%2E%2E%2fapi"}, True),
|
|
("/", {"test": "test/%2E%2E%2f%2E%2E%2fapi"}, True),
|
|
("/", {"test": "test/%252E%252E/api"}, True),
|
|
("/", {"test": "test/%252E%252E%2fapi"}, True),
|
|
(
|
|
"/",
|
|
{"test": "test/%2525252E%2525252E%2525252f%2525252E%2525252E%2525252fapi"},
|
|
True,
|
|
),
|
|
("/test/.%252E/api", {}, False),
|
|
("/test/%252E%252E/api", {}, False),
|
|
("/test/%2E%2E%2f%2E%2E%2fapi", {}, False),
|
|
("/test/%2525252E%2525252E%2525252f%2525252E%2525252E/api", {}, False),
|
|
("/", {"sql": ";UNION SELECT (a, b"}, True),
|
|
("/", {"sql": "UNION%20SELECT%20%28a%2C%20b"}, True),
|
|
("/UNION%20SELECT%20%28a%2C%20b", {}, False),
|
|
("/", {"sql": "concat(..."}, True),
|
|
("/", {"xss": "<script >"}, True),
|
|
("/<script >", {"xss": ""}, False),
|
|
("/%3Cscript%3E", {}, False),
|
|
],
|
|
)
|
|
async def test_bad_requests(
|
|
request_path: str,
|
|
request_params: dict[str, str],
|
|
fail_on_query_string: bool,
|
|
api_system: TestClient,
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
"""Test request paths that should be filtered."""
|
|
|
|
# Manual params handling
|
|
if request_params:
|
|
raw_params = "&".join(f"{val}={key}" for val, key in request_params.items())
|
|
man_params = f"?{raw_params}"
|
|
else:
|
|
man_params = ""
|
|
|
|
http = urllib3.PoolManager()
|
|
resp = await asyncio.get_running_loop().run_in_executor(
|
|
None,
|
|
http.request,
|
|
"GET",
|
|
f"http://{api_system.host}:{api_system.port}{request_path}{man_params}",
|
|
request_params,
|
|
)
|
|
|
|
assert resp.status == HTTPStatus.BAD_REQUEST
|
|
|
|
message = "Filtered a potential harmful request to:"
|
|
if fail_on_query_string:
|
|
message = "Filtered a request with a potential harmful query string:"
|
|
assert message in caplog.text
|
|
|
|
|
|
def _versioned_path(prefix: str, path: str) -> str:
|
|
"""Translate a v1 middleware path to the requested API version.
|
|
|
|
The v2 sub-app keeps the same paths behind a /v2 prefix, except the add-on
|
|
routes which are renamed /addons/... -> /apps/.... The role/bypass/core_only
|
|
expectations are otherwise identical, which is exactly the v1<->v2 pattern
|
|
parity these tests guard.
|
|
"""
|
|
if not prefix:
|
|
return path
|
|
return prefix + path.replace("/addons", "/apps")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("request_method", "request_path", "success_roles"),
|
|
[
|
|
("post", "/auth/reset", {"admin"}),
|
|
("get", "/auth/list", {"admin"}),
|
|
("delete", "/auth/cache", {"admin", "manager"}),
|
|
("get", "/auth", set(ROLE_ALL)),
|
|
("post", "/auth", set(ROLE_ALL)),
|
|
("get", "/backups/info", set(ROLE_ALL)),
|
|
("get", "/backups/abc123/download", {"admin", "manager", "backup"}),
|
|
("post", "/backups/new/full", {"admin", "manager", "backup"}),
|
|
("post", "/backups/abc123/restore/full", {"admin", "manager", "backup"}),
|
|
("get", "/core/info", set(ROLE_ALL)),
|
|
("post", "/core/update", {"admin", "manager", "homeassistant"}),
|
|
("post", "/core/restart", {"admin", "manager", "homeassistant"}),
|
|
("get", "/addons/self/options/config", set(ROLE_ALL)),
|
|
("post", "/addons/self/options", set(ROLE_ALL)),
|
|
("post", "/addons/self/restart", set(ROLE_ALL)),
|
|
("post", "/addons/self/security", {"admin"}),
|
|
("get", "/addons/abc123/options/config", {"admin", "manager"}),
|
|
("post", "/addons/abc123/options", {"admin", "manager"}),
|
|
("post", "/addons/abc123/restart", {"admin", "manager"}),
|
|
("post", "/addons/abc123/security", {"admin"}),
|
|
("post", "/os/datadisk/wipe", {"admin"}),
|
|
("post", "/addons/self/sys_options", set()),
|
|
("post", "/addons/abc123/sys_options", set()),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("plugin_tokens")
|
|
async def test_token_validation(
|
|
api_token_validation_with_prefix: tuple[TestClient, str],
|
|
install_app_example: App,
|
|
request_method: str,
|
|
request_path: str,
|
|
success_roles: set[str],
|
|
):
|
|
"""Test token validation paths on both API versions."""
|
|
client, prefix = api_token_validation_with_prefix
|
|
request_path = _versioned_path(prefix, request_path)
|
|
install_app_example.persist["access_token"] = "abc123"
|
|
install_app_example.data["hassio_api"] = True
|
|
for role in success_roles:
|
|
install_app_example.data["hassio_role"] = role
|
|
resp = await getattr(client, request_method)(
|
|
request_path, headers={"Authorization": "Bearer abc123"}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
for role in set(ROLE_ALL) - success_roles:
|
|
install_app_example.data["hassio_role"] = role
|
|
resp = await getattr(client, request_method)(
|
|
request_path, headers={"Authorization": "Bearer abc123"}
|
|
)
|
|
assert resp.status == 403
|
|
|
|
|
|
@pytest.mark.usefixtures("plugin_tokens")
|
|
async def test_home_assistant_paths(
|
|
api_token_validation_with_prefix: tuple[TestClient, str], coresys: CoreSys
|
|
):
|
|
"""Test Home Assistant only paths on both API versions."""
|
|
client, prefix = api_token_validation_with_prefix
|
|
coresys.homeassistant.supervisor_token = "abc123"
|
|
resp = await client.post(
|
|
_versioned_path(prefix, "/addons/local_test/sys_options"),
|
|
headers={"Authorization": "Bearer abc123"},
|
|
)
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.usefixtures("plugin_tokens")
|
|
async def test_blacklist(
|
|
api_token_validation_with_prefix: tuple[TestClient, str],
|
|
install_app_example: App,
|
|
):
|
|
"""Test the Core API hassio loopback is blocked on every API version."""
|
|
client, prefix = api_token_validation_with_prefix
|
|
install_app_example.persist["access_token"] = "abc123"
|
|
install_app_example.data["hassio_api"] = True
|
|
install_app_example.data["hassio_role"] = "admin"
|
|
|
|
# The hassio loopback is blacklisted regardless of role
|
|
resp = await client.get(
|
|
f"{prefix}/core/api/hassio/app", headers={"Authorization": "Bearer abc123"}
|
|
)
|
|
assert resp.status == 403
|
|
|
|
# A normal (non-hassio) Core API call through the same proxy is allowed
|
|
resp = await client.get(
|
|
f"{prefix}/core/api/states", headers={"Authorization": "Bearer abc123"}
|
|
)
|
|
assert resp.status == 200
|
|
|
|
|
|
@pytest.mark.usefixtures("plugin_tokens")
|
|
async def test_blacklist_legacy_alias(
|
|
api_token_validation: TestClient,
|
|
install_app_example: App,
|
|
):
|
|
"""Test the legacy /homeassistant proxy alias (v1 only) is blacklisted."""
|
|
install_app_example.persist["access_token"] = "abc123"
|
|
install_app_example.data["hassio_api"] = True
|
|
install_app_example.data["hassio_role"] = "admin"
|
|
|
|
resp = await api_token_validation.get(
|
|
"/homeassistant/api/hassio/app", headers={"Authorization": "Bearer abc123"}
|
|
)
|
|
assert resp.status == 403
|
|
|
|
|
|
async def test_api_security_system_stopping(api_system: TestClient, coresys: CoreSys):
|
|
"""Test API requests are rejected while the Supervisor is stopping."""
|
|
await coresys.core.set_state(CoreState.STOPPING)
|
|
|
|
resp = await api_system.get("/supervisor/ping")
|
|
result = await resp.json()
|
|
assert resp.status == 400
|
|
assert result["result"] == "error"
|