From cf3580a8e472c4819997aea9191b48a4e22a1c9a Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 21 Sep 2026 18:58:06 +0100 Subject: [PATCH] Use the hassio-provided Supervisor user for Unix socket auth (#182302) Co-authored-by: Claude Fable 5.1 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/hassio/__init__.py | 6 +-- homeassistant/components/hassio/auth.py | 9 ++-- .../components/hassio/config_flow.py | 10 ++-- homeassistant/components/hassio/const.py | 3 -- homeassistant/components/http/__init__.py | 13 ++--- homeassistant/components/http/auth.py | 46 +++++++--------- homeassistant/components/http/const.py | 11 +++- tests/components/hassio/test_auth.py | 4 +- tests/components/hassio/test_config_flow.py | 4 +- tests/components/hassio/test_init.py | 4 +- tests/components/http/test_auth.py | 53 +++++++++++++------ tests/components/http/test_init.py | 10 ++-- tests/components/websocket_api/test_auth.py | 5 +- 13 files changed, 95 insertions(+), 83 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 5285a5f2f411..67abae354947 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -18,6 +18,7 @@ from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import frontend from homeassistant.components.homeassistant import async_set_stop_handler +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.onboarding import async_is_onboarded from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry from homeassistant.const import EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, Platform @@ -57,7 +58,6 @@ from .const import ( ADDONS_COORDINATOR, DATA_COMPONENT, DATA_HASSIO_HOST, - DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, ENTRY_DATA_USER, @@ -340,7 +340,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if legacy_data is not None: legacy_user_id = legacy_data.get("hassio_user") - hass.data[DATA_HASSIO_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user( + hass.data[DATA_SUPERVISOR_USER] = await _async_get_or_create_supervisor_user( hass, entry, legacy_user_id ) @@ -386,7 +386,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: remove_legacy_store = True # Async setup runs first unconditionally and always populates this field - user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + user = hass.data[DATA_SUPERVISOR_USER] if entry.data.get(ENTRY_DATA_USER) != user.id: hass.config_entries.async_update_entry( entry, diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 03065b0b4c85..4f728ec81f41 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -15,12 +15,15 @@ import probatio from homeassistant.auth.providers import homeassistant as auth_ha from homeassistant.components.http import KEY_HASS, KEY_HASS_USER, HomeAssistantView -from homeassistant.components.http.const import is_supervisor_unix_socket_request +from homeassistant.components.http.const import ( + DATA_SUPERVISOR_USER, + is_supervisor_unix_socket_request, +) from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME, DATA_HASSIO_SUPERVISOR_USER +from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME _LOGGER = logging.getLogger(__name__) @@ -41,7 +44,7 @@ class HassIOBaseAuth(HomeAssistantView): def _check_access(self, request: web.Request) -> None: """Check if this call is from Supervisor.""" - user = self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + user = self.hass.data.get(DATA_SUPERVISOR_USER) if user is None: raise HTTPServiceUnavailable diff --git a/homeassistant/components/hassio/config_flow.py b/homeassistant/components/hassio/config_flow.py index 95c3733df773..54d9163fd7ca 100644 --- a/homeassistant/components/hassio/config_flow.py +++ b/homeassistant/components/hassio/config_flow.py @@ -2,15 +2,11 @@ from typing import Any +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import HASSIO_USER_NAME -from .const import ( - DATA_HASSIO_SUPERVISOR_USER, - DEFAULT_UPDATE_OPTIONS, - DOMAIN, - ENTRY_DATA_USER, -) +from .const import DEFAULT_UPDATE_OPTIONS, DOMAIN, ENTRY_DATA_USER class HassIoConfigFlow(ConfigFlow, domain=DOMAIN): @@ -23,7 +19,7 @@ class HassIoConfigFlow(ConfigFlow, domain=DOMAIN): ) -> ConfigFlowResult: """Handle the initial step.""" data: dict[str, Any] = {} - if (user := self.hass.data.get(DATA_HASSIO_SUPERVISOR_USER)) is not None: + if (user := self.hass.data.get(DATA_SUPERVISOR_USER)) is not None: data[ENTRY_DATA_USER] = user.id return self.async_create_entry( diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index d05a16fa2a79..8fd735023570 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -20,8 +20,6 @@ if TYPE_CHECKING: SupervisorInfo, ) - from homeassistant.auth.models import User - from .coordinator import ( HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, @@ -153,7 +151,6 @@ DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssuesCoordinator] = HassKey( ) DATA_KEY_MOUNTS = "mounts" DATA_HASSIO_HOST: HassKey[str] = HassKey("hassio_host") -DATA_HASSIO_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") ENTRY_DATA_USER = "user" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index cbc45ee5ad0a..e7dcaf626d5b 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -9,11 +9,7 @@ from typing import Any, Final import probatio from homeassistant.components.network import async_get_source_ip -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - HASSIO_USER_NAME, -) +from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir @@ -48,6 +44,7 @@ from .const import ( # noqa: F401 CONF_TRUSTED_PROXIES, CONF_USE_X_FORWARDED_FOR, CONF_USE_X_FRAME_OPTIONS, + DATA_SUPERVISOR_USER, DEFAULT_CORS, DOMAIN, KEY_HASS_REFRESH_TOKEN_ID, @@ -215,11 +212,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def start_supervisor_unix_socket(*_: Any) -> None: """Start the Unix socket after the Supervisor user is available.""" - if any( - user - for user in await hass.auth.async_get_users() - if user.system_generated and user.name == HASSIO_USER_NAME - ): + if hass.data.get(DATA_SUPERVISOR_USER) is not None: await server.async_start_supervisor_unix_socket() else: _LOGGER.error("Supervisor user not found; not starting Unix socket") diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index e2c6871939fc..702eb7c8adde 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -22,7 +22,6 @@ from yarl import URL from homeassistant.auth import jwt_wrapper from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.components import websocket_api -from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.http import current_request from homeassistant.helpers.json import json_bytes @@ -30,6 +29,7 @@ from homeassistant.helpers.storage import Store from .auth_util import async_user_not_allowed_do_auth from .const import ( + DATA_SUPERVISOR_USER, KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER, @@ -94,7 +94,7 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -async def async_setup_auth( # noqa: C901 +async def async_setup_auth( hass: HomeAssistant, app: Application, ) -> None: @@ -187,40 +187,30 @@ async def async_setup_auth( # noqa: C901 request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True - supervisor_user_id: str | None = None - async def async_authenticate_supervisor_unix_socket(request: Request) -> bool: """Authenticate a request from a Unix socket as the Supervisor user. The Unix Socket is dedicated and only available to Supervisor. To avoid the extra overhead and round trips for the authentication and refresh tokens, we directly authenticate requests from the socket as - the Supervisor user. + the Supervisor user provided by the hassio integration. The user is + looked up in the auth store so a user removed at runtime is not + authenticated any longer. """ - nonlocal supervisor_user_id + if (supervisor_user := hass.data.get(DATA_SUPERVISOR_USER)) is None or ( + user := await hass.auth.async_get_user(supervisor_user.id) + ) is None: + # The Unix socket should not be serving before the hassio integration + # has provided the Supervisor user. If we get here, something is wrong. + _LOGGER.error( + "Supervisor user not found; cannot authenticate Unix socket request" + ) + raise HTTPInternalServerError - # Fast path: use cached user ID - if supervisor_user_id is not None: - if user := await hass.auth.async_get_user(supervisor_user_id): - request[KEY_HASS_USER] = user - return True - supervisor_user_id = None - - # Slow path: find the Supervisor user by name - for user in await hass.auth.async_get_users(): - if user.system_generated and user.name == HASSIO_USER_NAME: - supervisor_user_id = user.id - # Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user - # doesn't use refresh tokens. - request[KEY_HASS_USER] = user - return True - - # The Unix socket should not be serving before the hassio integration - # has created the Supervisor user. If we get here, something is wrong. - _LOGGER.error( - "Supervisor user not found; cannot authenticate Unix socket request" - ) - raise HTTPInternalServerError + # Not setting KEY_HASS_REFRESH_TOKEN_ID since Supervisor user + # doesn't use refresh tokens. + request[KEY_HASS_USER] = user + return True @middleware async def auth_middleware( diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 24ada111c765..a53b45aeb5c0 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,10 +1,14 @@ """HTTP specific constants.""" -from typing import Final +from typing import TYPE_CHECKING, Final from aiohttp.web import Request from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 +from homeassistant.util.hass_dict import HassKey + +if TYPE_CHECKING: + from homeassistant.auth.models import User DOMAIN: Final = "http" @@ -12,6 +16,11 @@ KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" KEY_SUPERVISOR_UNIX_SOCKET: Final = "ha_supervisor_unix_socket" +# System user used to authenticate requests over the Supervisor Unix socket. +# Set by the hassio integration during its setup; the Unix socket is only +# started once it is available. +DATA_SUPERVISOR_USER: HassKey[User] = HassKey("hassio_supervisor_user") + CONF_SERVER_HOST: Final = "server_host" CONF_SERVER_PORT: Final = "server_port" CONF_BASE_URL: Final = "base_url" diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py index 29f08d3ef66b..86da1f215da2 100644 --- a/tests/components/hassio/test_auth.py +++ b/tests/components/hassio/test_auth.py @@ -10,7 +10,7 @@ import pytest from homeassistant.auth.providers.homeassistant import InvalidAuth from homeassistant.components.hassio.auth import HassIOBaseAuth -from homeassistant.components.hassio.const import DATA_HASSIO_SUPERVISOR_USER +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.core import HomeAssistant @@ -187,7 +187,7 @@ async def test_check_access_unix_socket_or_missing_peername( expectation: AbstractContextManager, ) -> None: """Test _check_access handles Unix socket requests and missing peername.""" - user = hass.data.get(DATA_HASSIO_SUPERVISOR_USER) + user = hass.data.get(DATA_SUPERVISOR_USER) assert user is not None auth_view = HassIOBaseAuth(hass) diff --git a/tests/components/hassio/test_config_flow.py b/tests/components/hassio/test_config_flow.py index 7905ab7c27cd..f7336773cf22 100644 --- a/tests/components/hassio/test_config_flow.py +++ b/tests/components/hassio/test_config_flow.py @@ -5,10 +5,10 @@ from unittest.mock import patch from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.hassio import DOMAIN from homeassistant.components.hassio.const import ( - DATA_HASSIO_SUPERVISOR_USER, DEFAULT_UPDATE_OPTIONS, ENTRY_DATA_USER, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -53,7 +53,7 @@ async def test_config_flow_uses_bootstrap_user(hass: HomeAssistant) -> None: user = await hass.auth.async_create_system_user( "Supervisor", group_ids=[GROUP_ID_ADMIN] ) - hass.data[DATA_HASSIO_SUPERVISOR_USER] = user + hass.data[DATA_SUPERVISOR_USER] = user with ( patch("homeassistant.components.hassio.async_setup", return_value=True), diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 7c57ef891cd8..c86a06e39a31 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -54,7 +54,6 @@ from homeassistant.components.hassio import ( hostname_from_addon_slug, ) from homeassistant.components.hassio.const import ( - DATA_HASSIO_SUPERVISOR_USER, DATA_KEY_SUPERVISOR_ISSUES, DEFAULT_UPDATE_OPTIONS, ENTRY_DATA_USER, @@ -68,6 +67,7 @@ from homeassistant.components.homeassistant import ( DOMAIN as HOMEASSISTANT_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import HASSIO_USER_NAME @@ -368,7 +368,7 @@ async def test_setup_api_push_api_data_default( supervisor_client.homeassistant.set_options.assert_called_once_with( HomeAssistantOptions(ssl=False, port=80, refresh_token=None) ) - hassio_user = hass.data[DATA_HASSIO_SUPERVISOR_USER] + hassio_user = hass.data[DATA_SUPERVISOR_USER] assert hassio_user.system_generated assert len(hassio_user.groups) == 1 assert hassio_user.groups[0].id == GROUP_ID_ADMIN diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 7e29303b460e..ff93c94cab6b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -28,6 +28,7 @@ from homeassistant.components.http.auth import ( async_sign_path, async_user_not_allowed_do_auth, ) +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.http.forwarded import async_setup_forwarded from homeassistant.components.http.request_context import ( current_request, @@ -766,7 +767,7 @@ async def test_unix_socket_auth_with_supervisor_user( supervisor_user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - await hass.auth.async_create_refresh_token(supervisor_user) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -798,16 +799,17 @@ async def test_unix_socket_auth_without_supervisor_user( assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR -async def test_unix_socket_auth_caches_user_id( +async def test_unix_socket_auth_removed_user( hass: HomeAssistant, app: web.Application, aiohttp_client: ClientSessionGenerator, ) -> None: - """Test that Unix socket auth caches the Supervisor user ID.""" + """Test that Unix socket requests fail once the Supervisor user was removed.""" supervisor_user = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] ) - await hass.auth.async_create_refresh_token(supervisor_user) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user + await hass.auth.async_remove_user(supervisor_user) await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -816,20 +818,37 @@ async def test_unix_socket_auth_caches_user_id( "homeassistant.components.http.auth.is_supervisor_unix_socket_request", return_value=True, ): - # First request triggers user lookup req = await client.get("/") - assert req.status == HTTPStatus.OK + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR - # Second request should use cached user ID - with ( - patch( - "homeassistant.components.http.auth.is_supervisor_unix_socket_request", - return_value=True, - ), - patch.object( - hass.auth, "async_get_users", wraps=hass.auth.async_get_users - ) as mock_get_users, + +async def test_unix_socket_auth_uses_provided_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test that Unix socket auth uses the user provided by hassio, not a name match. + + A stale duplicate system user named Supervisor can be left behind in the + auth store; requests must be authenticated as the user hassio actually uses. + """ + stale_user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + supervisor_user = await hass.auth.async_create_system_user( + HASSIO_USER_NAME, group_ids=[GROUP_ID_ADMIN] + ) + hass.data[DATA_SUPERVISOR_USER] = supervisor_user + + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + with patch( + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, ): req = await client.get("/") - assert req.status == HTTPStatus.OK - mock_get_users.assert_not_called() + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == supervisor_user.id + assert data["user_id"] != stale_user.id diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 29e6e7bc0f1a..5be711f3130b 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -31,7 +31,11 @@ from homeassistant.components.http.config import ( async_get_and_load_store, default_server_port, ) -from homeassistant.components.http.const import ENV_SETUP_PORT, ENV_SUPERVISOR +from homeassistant.components.http.const import ( + DATA_SUPERVISOR_USER, + ENV_SETUP_PORT, + ENV_SUPERVISOR, +) from homeassistant.const import EVENT_HOMEASSISTANT_STOP, HASSIO_USER_NAME, SERVER_PORT from homeassistant.core import CoreState, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -958,7 +962,7 @@ async def test_unix_socket_started_with_supervisor( tmp_path: Path, ) -> None: """Test unix socket is started when running under Supervisor.""" - await hass.auth.async_create_system_user( + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) socket_path = tmp_path / "core.sock" @@ -1027,7 +1031,7 @@ async def test_supervisor_http_config_view( tmp_path: Path, ) -> None: """Test the HTTP config view is registered and served over the socket only.""" - await hass.auth.async_create_system_user( + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] ) socket_path = tmp_path / "core.sock" diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 1e9cd3b0fb80..a97e7d8c8535 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -7,6 +7,7 @@ from aiohttp import WSMsgType, web import pytest from homeassistant.auth.providers.homeassistant import HassAuthProvider +from homeassistant.components.http.const import DATA_SUPERVISOR_USER from homeassistant.components.websocket_api import DOMAIN from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, @@ -436,8 +437,8 @@ async def test_unix_socket_auth_bypass( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None: """Test that Unix socket connections skip websocket auth phase.""" - # Create the Supervisor system user - await hass.auth.async_create_system_user( + # Create the Supervisor system user, as the hassio integration would + hass.data[DATA_SUPERVISOR_USER] = await hass.auth.async_create_system_user( HASSIO_USER_NAME, group_ids=["system-admin"] )