From 275374ec0d3d8a785dc07393bf867efef7973cc3 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 23 Feb 2026 23:00:14 +0100 Subject: [PATCH 01/21] Add Unix socket listener for Supervisor to Core communication When running under Supervisor (detected via SUPERVISOR env var), the HTTP server now additionally listens on a Unix socket at /run/core/http.sock. This enables efficient local IPC between Supervisor and Core without going through TCP. The Unix socket shares the same aiohttp app and runner, so all routes, middleware, and authentication are shared with the TCP server. The socket is started before the TCP site and cleaned up on shutdown. Co-Authored-By: Claude Opus 4.6 --- homeassistant/components/http/__init__.py | 29 ++++++++++- homeassistant/components/http/web_runner.py | 44 ++++++++++++++++ tests/components/http/test_init.py | 56 +++++++++++++++++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 75971b1ed1d5f1..df448d517b1a51 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -10,6 +10,7 @@ from ipaddress import IPv4Network, IPv6Network, ip_network import logging import os +from pathlib import Path import socket import ssl from tempfile import NamedTemporaryFile @@ -69,7 +70,7 @@ from .request_context import setup_request_context from .security_filter import setup_security_filter from .static import CACHE_HEADERS, CachingStaticResource -from .web_runner import HomeAssistantTCPSite +from .web_runner import HomeAssistantTCPSite, HomeAssistantUnixSite CONF_SERVER_HOST: Final = "server_host" CONF_SERVER_PORT: Final = "server_port" @@ -103,6 +104,8 @@ STORAGE_VERSION: Final = 1 SAVE_DELAY: Final = 180 +SUPERVISOR_UNIX_SOCKET_PATH: Final = Path("/run/core/http.sock") + _HAS_IPV6 = hasattr(socket, "AF_INET6") _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] @@ -244,6 +247,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_key=ssl_key, trusted_proxies=trusted_proxies, ssl_profile=ssl_profile, + unix_socket_path=SUPERVISOR_UNIX_SOCKET_PATH + if "SUPERVISOR" in os.environ + else None, ) await server.async_initialize( cors_origins=cors_origins, @@ -366,6 +372,7 @@ def __init__( server_port: int, trusted_proxies: list[IPv4Network | IPv6Network], ssl_profile: str, + unix_socket_path: Path | None = None, ) -> None: """Initialize the HTTP Home Assistant server.""" self.app = HomeAssistantApplication( @@ -384,8 +391,10 @@ def __init__( self.server_port = server_port self.trusted_proxies = trusted_proxies self.ssl_profile = ssl_profile + self.unix_socket_path = unix_socket_path self.runner: web.AppRunner | None = None self.site: HomeAssistantTCPSite | None = None + self.unix_site: HomeAssistantUnixSite | None = None self.context: ssl.SSLContext | None = None async def async_initialize( @@ -623,6 +632,20 @@ async def start(self) -> None: ) await self.runner.setup() + if self.unix_socket_path is not None: + self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path) + try: + await self.unix_site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server on unix socket %s: %s", + self.unix_socket_path, + error, + ) + self.unix_site = None + else: + _LOGGER.info("Now listening on unix socket %s", self.unix_socket_path) + self.site = HomeAssistantTCPSite( self.runner, self.server_host, self.server_port, ssl_context=self.context ) @@ -637,6 +660,10 @@ async def start(self) -> None: async def stop(self) -> None: """Stop the aiohttp server.""" + if self.unix_site is not None: + await self.unix_site.stop() + if self.unix_socket_path is not None: + self.unix_socket_path.unlink(missing_ok=True) if self.site is not None: await self.site.stop() if self.runner is not None: diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index f633433c9e4d54..634299481fd4bd 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from pathlib import Path from ssl import SSLContext from aiohttp import web @@ -68,3 +69,46 @@ async def start(self) -> None: reuse_address=self._reuse_address, reuse_port=self._reuse_port, ) + + +class HomeAssistantUnixSite(web.BaseSite): + """HomeAssistant specific aiohttp UnixSite. + + Listens on a Unix socket for local inter-process communication, + used for Supervisor to Core communication. + """ + + __slots__ = ("_path",) + + def __init__( + self, + runner: web.BaseRunner, + path: Path, + *, + backlog: int = 128, + ) -> None: + """Initialize HomeAssistantUnixSite.""" + super().__init__( + runner, + backlog=backlog, + ) + self._path = path + + @property + def name(self) -> str: + """Return server URL.""" + return f"http://unix:{self._path}:" + + async def start(self) -> None: + """Start server.""" + await super().start() + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.unlink(missing_ok=True) + loop = asyncio.get_running_loop() + server = self._runner.server + assert server is not None + self._server = await loop.create_unix_server( + server, + self._path, + backlog=self._backlog, + ) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 2f7517f8ecb008..12eca2f458ff47 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -6,6 +6,7 @@ from http import HTTPStatus from ipaddress import ip_network import logging +import os from pathlib import Path from unittest.mock import ANY, Mock, patch @@ -715,6 +716,10 @@ async def test_server_host( patch( "asyncio.BaseEventLoop.create_server", return_value=mock_server ) as mock_create_server, + patch( + "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", + return_value=mock_server, + ), ): assert await async_setup_component( hass, @@ -735,3 +740,54 @@ async def test_server_host( ) assert set(issue_registry.issues) == expected_issues + + +async def test_unix_socket_started_with_supervisor( + hass: HomeAssistant, + tmp_path: Path, +) -> None: + """Test unix socket is started when running under Supervisor.""" + socket_path = tmp_path / "core.sock" + mock_server = Mock() + with ( + patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), + patch("homeassistant.components.http.SUPERVISOR_UNIX_SOCKET_PATH", socket_path), + patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), + patch( + "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", + return_value=mock_server, + ) as mock_create_unix, + ): + assert await async_setup_component(hass, "http", {"http": {}}) + await hass.async_start() + await hass.async_block_till_done() + + mock_create_unix.assert_called_once_with( + ANY, + socket_path, + backlog=128, + ) + assert hass.http.unix_site is not None + + +async def test_unix_socket_not_started_without_supervisor( + hass: HomeAssistant, +) -> None: + """Test unix socket is not started when not running under Supervisor.""" + mock_server = Mock() + with ( + patch.dict(os.environ, {}, clear=False), + patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), + patch( + "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", + return_value=mock_server, + ) as mock_create_unix, + ): + # Ensure SUPERVISOR is not in the environment + os.environ.pop("SUPERVISOR", None) + assert await async_setup_component(hass, "http", {"http": {}}) + await hass.async_start() + await hass.async_block_till_done() + + mock_create_unix.assert_not_called() + assert hass.http.unix_site is None From 68d94badc622a272bd9e306f61565e15a30c9b35 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Mon, 23 Feb 2026 23:10:35 +0100 Subject: [PATCH 02/21] Use SUPERVISOR_CORE_API_SOCKET env var for unix socket path Replace the hardcoded socket path constant with the SUPERVISOR_CORE_API_SOCKET environment variable, allowing Supervisor to specify where Core should listen. Only absolute paths are accepted; relative paths are rejected with an error. Co-Authored-By: Claude Opus 4.6 --- homeassistant/components/http/__init__.py | 16 +++++++--- tests/components/http/test_init.py | 39 ++++++++++++++++++----- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index df448d517b1a51..82b9b062d5220b 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -104,8 +104,6 @@ STORAGE_VERSION: Final = 1 SAVE_DELAY: Final = 180 -SUPERVISOR_UNIX_SOCKET_PATH: Final = Path("/run/core/http.sock") - _HAS_IPV6 = hasattr(socket, "AF_INET6") _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] @@ -238,6 +236,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: source_ip_task = create_eager_task(async_get_source_ip(hass)) + unix_socket_path: Path | None = None + if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): + socket_path = Path(socket_env) + if socket_path.is_absolute(): + unix_socket_path = socket_path + else: + _LOGGER.error( + "Invalid unix socket path %s: path must be absolute", socket_env + ) + server = HomeAssistantHTTP( hass, server_host=server_host, @@ -247,9 +255,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_key=ssl_key, trusted_proxies=trusted_proxies, ssl_profile=ssl_profile, - unix_socket_path=SUPERVISOR_UNIX_SOCKET_PATH - if "SUPERVISOR" in os.environ - else None, + unix_socket_path=unix_socket_path, ) await server.async_initialize( cors_origins=cors_origins, diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 12eca2f458ff47..348c0c9ff75470 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -716,10 +716,6 @@ async def test_server_host( patch( "asyncio.BaseEventLoop.create_server", return_value=mock_server ) as mock_create_server, - patch( - "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", - return_value=mock_server, - ), ): assert await async_setup_component( hass, @@ -750,8 +746,9 @@ async def test_unix_socket_started_with_supervisor( socket_path = tmp_path / "core.sock" mock_server = Mock() with ( - patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), - patch("homeassistant.components.http.SUPERVISOR_UNIX_SOCKET_PATH", socket_path), + patch.dict( + os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False + ), patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), patch( "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", @@ -783,11 +780,37 @@ async def test_unix_socket_not_started_without_supervisor( return_value=mock_server, ) as mock_create_unix, ): - # Ensure SUPERVISOR is not in the environment - os.environ.pop("SUPERVISOR", None) + os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) + assert await async_setup_component(hass, "http", {"http": {}}) + await hass.async_start() + await hass.async_block_till_done() + + mock_create_unix.assert_not_called() + assert hass.http.unix_site is None + + +async def test_unix_socket_rejected_relative_path( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unix socket is rejected when path is relative.""" + mock_server = Mock() + with ( + patch.dict( + os.environ, + {"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"}, + clear=False, + ), + patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), + patch( + "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", + return_value=mock_server, + ) as mock_create_unix, + ): assert await async_setup_component(hass, "http", {"http": {}}) await hass.async_start() await hass.async_block_till_done() mock_create_unix.assert_not_called() assert hass.http.unix_site is None + assert "path must be absolute" in caplog.text From c5889082c0fc3163ec9267ce2344b5ace7fa4110 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Feb 2026 00:06:22 +0100 Subject: [PATCH 03/21] Authenticate Unix socket requests as the Supervisor user Requests arriving over the Unix socket are implicitly trusted and authenticated as the Supervisor system user, removing the need for token-based authentication on this channel. The ban middleware also skips IP-based checks for Unix socket connections since there is no remote IP address. Co-Authored-By: Claude Opus 4.6 --- homeassistant/components/http/auth.py | 39 +++++++++++-- homeassistant/components/http/ban.py | 6 +- homeassistant/components/http/const.py | 12 ++++ tests/components/http/test_auth.py | 78 +++++++++++++++++++++++++- tests/components/http/test_ban.py | 30 ++++++++++ 5 files changed, 159 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 227ee074439e39..4e2ad0f176a9b6 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -20,6 +20,7 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User 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 @@ -27,7 +28,12 @@ from homeassistant.helpers.storage import Store from homeassistant.util.network import is_local -from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER +from .const import ( + KEY_AUTHENTICATED, + KEY_HASS_REFRESH_TOKEN_ID, + KEY_HASS_USER, + is_unix_socket_request, +) _LOGGER = logging.getLogger(__name__) @@ -117,7 +123,7 @@ def async_user_not_allowed_do_auth( return "User cannot authenticate remotely" -async def async_setup_auth( +async def async_setup_auth( # noqa: C901 hass: HomeAssistant, app: Application, ) -> None: @@ -207,6 +213,27 @@ def async_validate_signed_request(request: Request) -> bool: request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True + supervisor_user_id: str | None = None + + async def async_authenticate_unix_socket(request: Request) -> bool: + """Authenticate a request from a Unix socket as the Supervisor user.""" + nonlocal supervisor_user_id + + # 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 + request[KEY_HASS_USER] = user + return True + return False + @middleware async def auth_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] @@ -214,7 +241,11 @@ async def auth_middleware( """Authenticate as middleware.""" authenticated = False - if hdrs.AUTHORIZATION in request.headers and async_validate_auth_header( + if is_unix_socket_request(request): + authenticated = await async_authenticate_unix_socket(request) + auth_type = "unix socket" + + elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header( request ): authenticated = True @@ -233,7 +264,7 @@ async def auth_middleware( if authenticated and _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Authenticated %s for %s using %s", - request.remote, + request.remote or "unknown", request.path, auth_type, ) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index e9ebdb6bfc7e14..651065c6987918 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -30,7 +30,7 @@ from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio from homeassistant.util import dt as dt_util, yaml as yaml_util -from .const import KEY_HASS +from .const import KEY_HASS, is_unix_socket_request from .view import HomeAssistantView _LOGGER: Final = logging.getLogger(__name__) @@ -72,6 +72,10 @@ async def ban_middleware( request: Request, handler: Callable[[Request], Awaitable[StreamResponse]] ) -> StreamResponse: """IP Ban middleware.""" + # Unix socket connections are trusted, skip ban checks + if is_unix_socket_request(request): + return await handler(request) + if (ban_manager := request.app.get(KEY_BAN_MANAGER)) is None: _LOGGER.error("IP Ban middleware loaded but banned IPs not loaded") return await handler(request) diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 1a5d7a603d75f3..51106bd6e1d7eb 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,10 +1,22 @@ """HTTP specific constants.""" +import socket from typing import Final +from aiohttp.web import Request + from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS # noqa: F401 DOMAIN: Final = "http" KEY_HASS_USER: Final = "hass_user" KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" + + +def is_unix_socket_request(request: Request) -> bool: + """Check if request arrived over a Unix socket.""" + if (transport := request.transport) is None: + return False + if (sock := transport.get_extra_info("socket")) is None: + return False + return bool(sock.family == socket.AF_UNIX) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index ca66b8fef4be28..378c32ab2fe5da 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -13,7 +13,7 @@ import pytest import yarl -from homeassistant.auth.const import GROUP_ID_READ_ONLY +from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.auth.providers import trusted_networks from homeassistant.auth.providers.homeassistant import HassAuthProvider @@ -32,6 +32,7 @@ current_request, setup_request_context, ) +from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.http import KEY_AUTHENTICATED, KEY_HASS from homeassistant.setup import async_setup_component @@ -658,3 +659,78 @@ async def test_create_user_once(hass: HomeAssistant) -> None: # test it did not create a user assert len(await hass.auth.async_get_users()) == cur_users + 1 + + +async def test_unix_socket_auth_with_supervisor_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test that Unix socket requests are authenticated as 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) + + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + with patch( + "homeassistant.components.http.auth.is_unix_socket_request", return_value=True + ): + req = await client.get("/") + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == supervisor_user.id + + +async def test_unix_socket_auth_without_supervisor_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test that Unix socket requests fail when no Supervisor user exists.""" + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + with patch( + "homeassistant.components.http.auth.is_unix_socket_request", return_value=True + ): + req = await client.get("/") + assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_unix_socket_auth_caches_user_id( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, +) -> None: + """Test that Unix socket auth caches the Supervisor user ID.""" + 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) + + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + with patch( + "homeassistant.components.http.auth.is_unix_socket_request", return_value=True + ): + # First request triggers user lookup + req = await client.get("/") + assert req.status == HTTPStatus.OK + + # Second request should use cached user ID + with ( + patch( + "homeassistant.components.http.auth.is_unix_socket_request", + return_value=True, + ), + patch.object( + hass.auth, "async_get_users", wraps=hass.auth.async_get_users + ) as mock_get_users, + ): + req = await client.get("/") + assert req.status == HTTPStatus.OK + mock_get_users.assert_not_called() diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 8d9d1a83a040ef..ad0e9acd8346bf 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -465,3 +465,33 @@ async def unauth_handler(request): await manager.async_add_ban(remote_ip) assert m_open.call_count == 1 + + +async def test_unix_socket_skips_ban_check( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator +) -> None: + """Test that Unix socket requests bypass ban middleware.""" + app = web.Application() + app[KEY_HASS] = hass + setup_bans(hass, app, 5) + set_real_ip = mock_real_ip(app) + + with patch( + "homeassistant.components.http.ban.load_yaml_config_file", + return_value={ + banned_ip: {"banned_at": "2016-11-16T19:20:03"} for banned_ip in BANNED_IPS + }, + ): + client = await aiohttp_client(app) + + # Verify the IP is actually banned for normal requests + set_real_ip(BANNED_IPS[0]) + resp = await client.get("/") + assert resp.status == HTTPStatus.FORBIDDEN + + # Unix socket requests should bypass ban checks + with patch( + "homeassistant.components.http.ban.is_unix_socket_request", return_value=True + ): + resp = await client.get("/") + assert resp.status == HTTPStatus.NOT_FOUND From 72db92b17be6374c1933cc453ba7c7df29b5da91 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Feb 2026 13:43:02 +0100 Subject: [PATCH 04/21] Restrict Unix socket permissions before accepting connections Create the socket with start_serving=False, chmod to 0600, then start serving. This avoids a race window where the socket could accept connections before permissions are restricted. Co-Authored-By: Claude Opus 4.6 --- homeassistant/components/http/web_runner.py | 3 +++ tests/components/http/test_init.py | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 634299481fd4bd..42358adc2bbb1d 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -111,4 +111,7 @@ async def start(self) -> None: server, self._path, backlog=self._backlog, + start_serving=False, ) + self._path.chmod(0o600) + await self._server.start_serving() diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 348c0c9ff75470..83406e94e9996f 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -744,16 +744,17 @@ async def test_unix_socket_started_with_supervisor( ) -> None: """Test unix socket is started when running under Supervisor.""" socket_path = tmp_path / "core.sock" - mock_server = Mock() + mock_unix_server = AsyncMock() with ( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False ), - patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), + patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), patch( "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", - return_value=mock_server, + return_value=mock_unix_server, ) as mock_create_unix, + patch.object(Path, "chmod") as mock_chmod, ): assert await async_setup_component(hass, "http", {"http": {}}) await hass.async_start() @@ -763,7 +764,10 @@ async def test_unix_socket_started_with_supervisor( ANY, socket_path, backlog=128, + start_serving=False, ) + mock_chmod.assert_called_once_with(0o600) + mock_unix_server.start_serving.assert_awaited_once() assert hass.http.unix_site is not None From b6be7a12b18af9b50bd40fb2583e8844855ceece Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Feb 2026 13:52:38 +0100 Subject: [PATCH 05/21] Patch loop instance instead of private asyncio class in tests Replace patching asyncio.unix_events._UnixSelectorEventLoop with patch.object on the running loop instance. This avoids depending on a private CPython implementation detail. Co-Authored-By: Claude Opus 4.6 --- tests/components/http/test_init.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 83406e94e9996f..62842c07d2b9e7 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -744,15 +744,15 @@ async def test_unix_socket_started_with_supervisor( ) -> None: """Test unix socket is started when running under Supervisor.""" socket_path = tmp_path / "core.sock" + loop = asyncio.get_event_loop() mock_unix_server = AsyncMock() with ( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False ), patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), - patch( - "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", - return_value=mock_unix_server, + patch.object( + loop, "create_unix_server", return_value=mock_unix_server ) as mock_create_unix, patch.object(Path, "chmod") as mock_chmod, ): @@ -775,13 +775,13 @@ async def test_unix_socket_not_started_without_supervisor( hass: HomeAssistant, ) -> None: """Test unix socket is not started when not running under Supervisor.""" + loop = asyncio.get_event_loop() mock_server = Mock() with ( patch.dict(os.environ, {}, clear=False), patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), - patch( - "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", - return_value=mock_server, + patch.object( + loop, "create_unix_server", return_value=mock_server ) as mock_create_unix, ): os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) @@ -798,6 +798,7 @@ async def test_unix_socket_rejected_relative_path( caplog: pytest.LogCaptureFixture, ) -> None: """Test unix socket is rejected when path is relative.""" + loop = asyncio.get_event_loop() mock_server = Mock() with ( patch.dict( @@ -806,9 +807,8 @@ async def test_unix_socket_rejected_relative_path( clear=False, ), patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), - patch( - "asyncio.unix_events._UnixSelectorEventLoop.create_unix_server", - return_value=mock_server, + patch.object( + loop, "create_unix_server", return_value=mock_server ) as mock_create_unix, ): assert await async_setup_component(hass, "http", {"http": {}}) From f499a0b45b780102708c2f13b919f209d7ad3fca Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 14:58:00 +0100 Subject: [PATCH 06/21] Extend docs and add comments to Unix socket authentication logic --- homeassistant/components/http/auth.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 4e2ad0f176a9b6..86939017044d29 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -216,7 +216,13 @@ def async_validate_signed_request(request: Request) -> bool: supervisor_user_id: str | None = None async def async_authenticate_unix_socket(request: Request) -> bool: - """Authenticate a request from a Unix socket as the Supervisor user.""" + """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. + """ nonlocal supervisor_user_id # Fast path: use cached user ID @@ -230,6 +236,8 @@ async def async_authenticate_unix_socket(request: Request) -> bool: 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 return False From ea556d65cb2cbbef093d358836f25978d3e9d0e6 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 15:14:26 +0100 Subject: [PATCH 07/21] Improve removing Unix socket on shutdown Handle OSErrors when removing the Unix socket on shutdown, and run the unlink in the executor to avoid blocking the event loop. --- homeassistant/components/http/__init__.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 82b9b062d5220b..4e4c688a5ab140 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -669,7 +669,16 @@ async def stop(self) -> None: if self.unix_site is not None: await self.unix_site.stop() if self.unix_socket_path is not None: - self.unix_socket_path.unlink(missing_ok=True) + try: + await self.hass.async_add_executor_job( + self.unix_socket_path.unlink, True + ) + except OSError as err: + _LOGGER.warning( + "Could not remove unix socket %s: %s", + self.unix_socket_path, + err, + ) if self.site is not None: await self.site.stop() if self.runner is not None: From cccb252b8d7af49ed6a623f5d74843e16c1dc4c6 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 15:30:11 +0100 Subject: [PATCH 08/21] Add comment about why we delay start serving --- homeassistant/components/http/web_runner.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 42358adc2bbb1d..7341581ac11346 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -113,5 +113,7 @@ async def start(self) -> None: backlog=self._backlog, start_serving=False, ) + # Serve after we've correctly set up the socket file with the minimal + # permissions. self._path.chmod(0o600) await self._server.start_serving() From da29f06c2c2b383ad6c7930f81da8e4a45f4bf4d Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 16:17:49 +0100 Subject: [PATCH 09/21] Move potentially blocking I/O into executor --- homeassistant/components/http/web_runner.py | 32 +++++++++++------ tests/components/http/test_init.py | 38 +++++++-------------- 2 files changed, 34 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index 7341581ac11346..f18c27c2081b5d 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -4,6 +4,7 @@ import asyncio from pathlib import Path +import socket from ssl import SSLContext from aiohttp import web @@ -99,21 +100,32 @@ def name(self) -> str: """Return server URL.""" return f"http://unix:{self._path}:" + def _create_unix_socket(self) -> socket.socket: + """Create and bind a Unix domain socket. + + Performs blocking filesystem I/O (mkdir, unlink, chmod) and is + intended to be run in an executor. Permissions are set after bind + but before the socket is handed to the event loop, so no + connections can arrive on an unrestricted socket. + """ + self._path.parent.mkdir(parents=True, exist_ok=True) + self._path.unlink(missing_ok=True) + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + sock.bind(str(self._path)) + except OSError: + sock.close() + raise + self._path.chmod(0o600) + return sock + async def start(self) -> None: """Start server.""" await super().start() - self._path.parent.mkdir(parents=True, exist_ok=True) - self._path.unlink(missing_ok=True) loop = asyncio.get_running_loop() + sock = await loop.run_in_executor(None, self._create_unix_socket) server = self._runner.server assert server is not None self._server = await loop.create_unix_server( - server, - self._path, - backlog=self._backlog, - start_serving=False, + server, sock=sock, backlog=self._backlog ) - # Serve after we've correctly set up the socket file with the minimal - # permissions. - self._path.chmod(0o600) - await self._server.start_serving() diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index df4f6bfc8457f4..b2bd37ad7b10ff 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -8,7 +8,7 @@ import logging import os from pathlib import Path -from unittest.mock import ANY, AsyncMock, Mock, patch +from unittest.mock import ANY, Mock, patch import pytest @@ -745,29 +745,27 @@ async def test_unix_socket_started_with_supervisor( """Test unix socket is started when running under Supervisor.""" socket_path = tmp_path / "core.sock" loop = asyncio.get_event_loop() - mock_unix_server = AsyncMock() + mock_sock = Mock() with ( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": str(socket_path)}, clear=False ), patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), + patch( + "homeassistant.components.http.web_runner.HomeAssistantUnixSite" + "._create_unix_socket", + return_value=mock_sock, + ) as mock_create_sock, patch.object( - loop, "create_unix_server", return_value=mock_unix_server + loop, "create_unix_server", return_value=Mock() ) as mock_create_unix, - patch.object(Path, "chmod") as mock_chmod, ): assert await async_setup_component(hass, "http", {"http": {}}) await hass.async_start() await hass.async_block_till_done() - mock_create_unix.assert_called_once_with( - ANY, - socket_path, - backlog=128, - start_serving=False, - ) - mock_chmod.assert_called_once_with(0o600) - mock_unix_server.start_serving.assert_awaited_once() + mock_create_sock.assert_called_once() + mock_create_unix.assert_called_once_with(ANY, sock=mock_sock, backlog=128) assert hass.http.unix_site is not None @@ -775,21 +773,15 @@ async def test_unix_socket_not_started_without_supervisor( hass: HomeAssistant, ) -> None: """Test unix socket is not started when not running under Supervisor.""" - loop = asyncio.get_event_loop() - mock_server = Mock() with ( patch.dict(os.environ, {}, clear=False), - patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), - patch.object( - loop, "create_unix_server", return_value=mock_server - ) as mock_create_unix, + patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): os.environ.pop("SUPERVISOR_CORE_API_SOCKET", None) assert await async_setup_component(hass, "http", {"http": {}}) await hass.async_start() await hass.async_block_till_done() - mock_create_unix.assert_not_called() assert hass.http.unix_site is None @@ -798,23 +790,17 @@ async def test_unix_socket_rejected_relative_path( caplog: pytest.LogCaptureFixture, ) -> None: """Test unix socket is rejected when path is relative.""" - loop = asyncio.get_event_loop() - mock_server = Mock() with ( patch.dict( os.environ, {"SUPERVISOR_CORE_API_SOCKET": "relative/path.sock"}, clear=False, ), - patch("asyncio.BaseEventLoop.create_server", return_value=mock_server), - patch.object( - loop, "create_unix_server", return_value=mock_server - ) as mock_create_unix, + patch("asyncio.BaseEventLoop.create_server", return_value=Mock()), ): assert await async_setup_component(hass, "http", {"http": {}}) await hass.async_start() await hass.async_block_till_done() - mock_create_unix.assert_not_called() assert hass.http.unix_site is None assert "path must be absolute" in caplog.text From fdde93187aa8c2860f5760bdd11dd947aa8151ec Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 16:53:47 +0100 Subject: [PATCH 10/21] Handle missing refresh token id gracefully --- homeassistant/components/onboarding/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 4e2f6a18e0d602..b78b789d5e2171 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -283,7 +283,10 @@ class IntegrationOnboardingView(_BaseOnboardingStepView): async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle token creation.""" hass = request.app[KEY_HASS] - refresh_token_id = request[KEY_HASS_REFRESH_TOKEN_ID] + if not (refresh_token_id := request.get(KEY_HASS_REFRESH_TOKEN_ID)): + return self.json_message( + "Refresh token not available", HTTPStatus.FORBIDDEN + ) async with self._lock: if self._async_is_done(): From 88b9e6cd83d9323df7b6b509a9a17c12252baa22 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 18:39:13 +0100 Subject: [PATCH 11/21] Move Unix socket websocket auth bypass into AuthPhase Consolidate all connection-creation logic in the auth module by adding async_handle_unix_socket() to AuthPhase, instead of constructing ActiveConnection directly in http.py. This moves ActiveConnection back to a TYPE_CHECKING-only import in http.py and keeps auth logic in one place. Co-Authored-By: Claude Opus 4.6 --- .../components/websocket_api/auth.py | 14 ++++ .../components/websocket_api/connection.py | 4 +- .../components/websocket_api/http.py | 67 +++++++++++-------- tests/components/websocket_api/test_auth.py | 41 ++++++++++++ 4 files changed, 95 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index a15f76632c1ce1..fffe69cd53d99e 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -10,6 +10,7 @@ from voluptuous.humanize import humanize_error from homeassistant.components.http.ban import process_success_login, process_wrong_login +from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.const import __version__ from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.json import json_bytes @@ -68,6 +69,19 @@ def __init__( # send_bytes_text will directly send a message to the client. self._send_bytes_text = send_bytes_text + async def async_handle_unix_socket(self) -> ActiveConnection: + """Handle a pre-authenticated Unix socket connection.""" + conn = ActiveConnection( + self._logger, + self._hass, + self._send_message, + self._request[KEY_HASS_USER], + refresh_token=None, + ) + await self._send_bytes_text(AUTH_OK_MESSAGE) + self._logger.debug("Auth OK (unix socket)") + return conn + async def async_handle(self, msg: JsonValueType) -> ActiveConnection: """Handle authentication.""" try: diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 12473c8625580e..dad8ebe5686e24 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -59,14 +59,14 @@ def __init__( hass: HomeAssistant, send_message: Callable[[bytes | str | dict[str, Any]], None], user: User, - refresh_token: RefreshToken, + refresh_token: RefreshToken | None, ) -> None: """Initialize an active connection.""" self.logger = logger self.hass = hass self.send_message = send_message self.user = user - self.refresh_token_id = refresh_token.id + self.refresh_token_id = refresh_token.id if refresh_token else None self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 self.can_coalesce = False diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 0e9e0eb69330c9..98f0206b35d818 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,6 +14,7 @@ from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView +from homeassistant.components.http.const import is_unix_socket_request from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -36,12 +37,12 @@ from .messages import message_to_json_bytes from .util import describe_request -CLOSE_MSG_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING} -AUTH_MESSAGE_TIMEOUT = 10 # seconds - if TYPE_CHECKING: from .connection import ActiveConnection +CLOSE_MSG_TYPES = {WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING} +AUTH_MESSAGE_TIMEOUT = 10 # seconds + _WS_LOGGER: Final = logging.getLogger(f"{__name__}.connection") @@ -386,37 +387,45 @@ async def _async_handle_auth_phase( send_bytes_text: Callable[[bytes], Coroutine[Any, Any, None]], ) -> ActiveConnection: """Handle the auth phase of the websocket connection.""" - await send_bytes_text(AUTH_REQUIRED_MESSAGE) + request = self._request - # Auth Phase - try: - msg = await self._wsock.receive(AUTH_MESSAGE_TIMEOUT) - except TimeoutError as err: - raise Disconnect( - f"Did not receive auth message within {AUTH_MESSAGE_TIMEOUT} seconds" - ) from err - - if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): - raise Disconnect("Received close message during auth phase") - - if msg.type is not WSMsgType.TEXT: - if msg.type is WSMsgType.ERROR: - # msg.data is the exception + if is_unix_socket_request(request): + # Unix socket requests are pre-authenticated by the HTTP + # auth middleware — skip the token exchange. + connection = await auth.async_handle_unix_socket() + else: + await send_bytes_text(AUTH_REQUIRED_MESSAGE) + + # Auth Phase + try: + msg = await self._wsock.receive(AUTH_MESSAGE_TIMEOUT) + except TimeoutError as err: raise Disconnect( - f"Received error message during auth phase: {msg.data}" + f"Did not receive auth message within {AUTH_MESSAGE_TIMEOUT} seconds" + ) from err + + if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSED, WSMsgType.CLOSING): + raise Disconnect("Received close message during auth phase") + + if msg.type is not WSMsgType.TEXT: + if msg.type is WSMsgType.ERROR: + # msg.data is the exception + raise Disconnect( + f"Received error message during auth phase: {msg.data}" + ) + raise Disconnect( + f"Received non-Text message of type {msg.type} during auth phase" ) - raise Disconnect( - f"Received non-Text message of type {msg.type} during auth phase" - ) - try: - auth_msg_data = json_loads(msg.data) - except ValueError as err: - raise Disconnect("Received invalid JSON during auth phase") from err + try: + auth_msg_data = json_loads(msg.data) + except ValueError as err: + raise Disconnect("Received invalid JSON during auth phase") from err + + if self._debug: + self._logger.debug("%s: Received %s", self.description, auth_msg_data) + connection = await auth.async_handle(auth_msg_data) - if self._debug: - self._logger.debug("%s: Received %s", self.description, auth_msg_data) - connection = await auth.async_handle(auth_msg_data) # As the webserver is now started before the start # event we do not want to block for websocket responses # diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 49ee593fed7eef..9e0c39ecdb9b6f 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -18,6 +18,7 @@ SIGNAL_WEBSOCKET_DISCONNECTED, URL, ) +from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component @@ -367,3 +368,43 @@ async def test_error_right_after_auth_disconnects( assert close_error_msg.type is WSMsgType.CLOSE assert "Received error message during command phase: explode" in caplog.text + + +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( + HASSIO_USER_NAME, group_ids=["system-admin"] + ) + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + client = await hass_client_no_auth() + + with ( + patch( + "homeassistant.components.http.ban.is_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.http.auth.is_unix_socket_request", + return_value=True, + ), + patch( + "homeassistant.components.websocket_api.http.is_unix_socket_request", + return_value=True, + ), + ): + async with client.ws_connect(URL) as ws: + # Should immediately receive auth_ok without sending a token + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + # Verify the connection works by sending a ping + await ws.send_json({"id": 1, "type": "ping"}) + pong_msg = await ws.receive_json() + assert pong_msg["type"] == "pong" + assert pong_msg["id"] == 1 From d93b45fe351ddc21512c0f28d9140023551ea0cf Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 19:02:13 +0100 Subject: [PATCH 12/21] Create Unix socket only after hassio is loaded This avoids a race condition where the Supervisor user has not been created yet, which causes unix socket authentication bypass to fail. --- homeassistant/components/http/__init__.py | 45 ++++++++++++++++------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 4e4c688a5ab140..b32b20c06e9fe4 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -279,6 +279,14 @@ async def start_server(*_: Any) -> None: async_when_setup_or_start(hass, "frontend", start_server) + if server.unix_socket_path is not None: + + async def start_unix_socket(*_: Any) -> None: + """Start the Unix socket after the Supervisor user is available.""" + await server.async_start_unix_socket() + + async_when_setup_or_start(hass, "hassio", start_unix_socket) + hass.http = server local_ip = await source_ip_task @@ -625,6 +633,29 @@ def _create_emergency_ssl_context(self) -> ssl.SSLContext: context.load_cert_chain(cert_pem.name, key_pem.name) return context + async def async_start_unix_socket(self) -> None: + """Start listening on the Unix socket. + + This is called separately from start() to delay serving the Unix + socket until the Supervisor user exists (created by the hassio + integration). Without this delay, Supervisor could connect before + its user is available and receive 401 responses it won't retry. + """ + if self.unix_socket_path is None or self.runner is None: + return + self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path) + try: + await self.unix_site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server on unix socket %s: %s", + self.unix_socket_path, + error, + ) + self.unix_site = None + else: + _LOGGER.info("Now listening on unix socket %s", self.unix_socket_path) + async def start(self) -> None: """Start the aiohttp server.""" # Aiohttp freezes apps after start so that no changes can be made. @@ -638,20 +669,6 @@ async def start(self) -> None: ) await self.runner.setup() - if self.unix_socket_path is not None: - self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path) - try: - await self.unix_site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server on unix socket %s: %s", - self.unix_socket_path, - error, - ) - self.unix_site = None - else: - _LOGGER.info("Now listening on unix socket %s", self.unix_socket_path) - self.site = HomeAssistantTCPSite( self.runner, self.server_host, self.server_port, ssl_context=self.context ) From 58d8824a441143f20a888845a43b692d156f8bef Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 19:04:02 +0100 Subject: [PATCH 13/21] Fail hard if Supervisor user does not exist --- homeassistant/components/http/auth.py | 16 ++++++++++++++-- tests/components/http/test_auth.py | 4 ++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 86939017044d29..5fcccfb67e860c 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -11,7 +11,13 @@ from typing import Any, Final from aiohttp import hdrs -from aiohttp.web import Application, Request, StreamResponse, middleware +from aiohttp.web import ( + Application, + HTTPInternalServerError, + Request, + StreamResponse, + middleware, +) import jwt from jwt import api_jws from yarl import URL @@ -240,7 +246,13 @@ async def async_authenticate_unix_socket(request: Request) -> bool: # doesn't use refresh tokens. request[KEY_HASS_USER] = user return True - return False + + # 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 @middleware async def auth_middleware( diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 378c32ab2fe5da..523eb24849492b 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -689,7 +689,7 @@ async def test_unix_socket_auth_without_supervisor_user( app: web.Application, aiohttp_client: ClientSessionGenerator, ) -> None: - """Test that Unix socket requests fail when no Supervisor user exists.""" + """Test that Unix socket requests return 500 when no Supervisor user exists.""" await async_setup_auth(hass, app) client = await aiohttp_client(app) @@ -697,7 +697,7 @@ async def test_unix_socket_auth_without_supervisor_user( "homeassistant.components.http.auth.is_unix_socket_request", return_value=True ): req = await client.get("/") - assert req.status == HTTPStatus.UNAUTHORIZED + assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR async def test_unix_socket_auth_caches_user_id( From f0c56d74a4117c4e1a61ddfbcc1af25336066d08 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 19:36:34 +0100 Subject: [PATCH 14/21] Use get_running_loop() in tests Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- tests/components/http/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b2bd37ad7b10ff..5211f7db85b183 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -744,7 +744,7 @@ async def test_unix_socket_started_with_supervisor( ) -> None: """Test unix socket is started when running under Supervisor.""" socket_path = tmp_path / "core.sock" - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() mock_sock = Mock() with ( patch.dict( From 03817ccc07e1c665fe2d4e9e86e3cd448df7b3af Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Wed, 11 Mar 2026 19:58:22 +0100 Subject: [PATCH 15/21] Check for Supervisor user existence before starting Unix socket --- homeassistant/components/http/__init__.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index b32b20c06e9fe4..781cbd4fb3ac81 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -34,6 +34,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + HASSIO_USER_NAME, SERVER_PORT, ) from homeassistant.core import Event, HomeAssistant, callback @@ -283,7 +284,14 @@ async def start_server(*_: Any) -> None: async def start_unix_socket(*_: Any) -> None: """Start the Unix socket after the Supervisor user is available.""" - await server.async_start_unix_socket() + if any( + user + for user in await hass.auth.async_get_users() + if user.system_generated and user.name == HASSIO_USER_NAME + ): + await server.async_start_unix_socket() + else: + _LOGGER.error("Supervisor user not found; not starting Unix socket") async_when_setup_or_start(hass, "hassio", start_unix_socket) From 0888dcc1da5277db120e385018fb6809cd1902af Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 17 Mar 2026 11:45:27 +0100 Subject: [PATCH 16/21] Fix test_unix_socket_started_with_supervisor pytest --- tests/components/http/test_init.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 5211f7db85b183..a76c82a45ace8a 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -15,6 +15,7 @@ from homeassistant.auth.providers.homeassistant import HassAuthProvider from homeassistant.components import cloud, http from homeassistant.components.cloud import CloudNotAvailable +from homeassistant.const import HASSIO_USER_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.http import KEY_HASS @@ -743,6 +744,9 @@ 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( + HASSIO_USER_NAME, group_ids=["system-admin"] + ) socket_path = tmp_path / "core.sock" loop = asyncio.get_running_loop() mock_sock = Mock() From 4ce712bdc7b2b167d728bda53d959ac83a22e608 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Mar 2026 16:41:31 +0100 Subject: [PATCH 17/21] Rename unix_socket to supervisor_unix_socket for clarity All Unix socket variables, methods, and functions are renamed to include "supervisor" since the socket is dedicated to Supervisor communication and implicitly authenticates as the Supervisor user. The is_supervisor_unix_socket_request() check now verifies the request arrived on the specific Supervisor socket path (via transport sockname) rather than just checking for any AF_UNIX socket, making it safe for future additional Unix sockets. Co-Authored-By: Claude Opus 4.6 (1M context) --- homeassistant/components/http/__init__.py | 48 ++++++++++--------- homeassistant/components/http/auth.py | 8 ++-- homeassistant/components/http/ban.py | 4 +- homeassistant/components/http/const.py | 12 +++-- .../components/websocket_api/auth.py | 2 +- .../components/websocket_api/http.py | 6 +-- tests/components/http/test_auth.py | 11 +++-- tests/components/http/test_ban.py | 3 +- tests/components/http/test_init.py | 6 +-- tests/components/websocket_api/test_auth.py | 6 +-- 10 files changed, 58 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 781cbd4fb3ac81..3e210ca391d04c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -237,11 +237,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: source_ip_task = create_eager_task(async_get_source_ip(hass)) - unix_socket_path: Path | None = None + supervisor_unix_socket_path: Path | None = None if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"): socket_path = Path(socket_env) if socket_path.is_absolute(): - unix_socket_path = socket_path + supervisor_unix_socket_path = socket_path else: _LOGGER.error( "Invalid unix socket path %s: path must be absolute", socket_env @@ -256,7 +256,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ssl_key=ssl_key, trusted_proxies=trusted_proxies, ssl_profile=ssl_profile, - unix_socket_path=unix_socket_path, + supervisor_unix_socket_path=supervisor_unix_socket_path, ) await server.async_initialize( cors_origins=cors_origins, @@ -280,20 +280,20 @@ async def start_server(*_: Any) -> None: async_when_setup_or_start(hass, "frontend", start_server) - if server.unix_socket_path is not None: + if server.supervisor_unix_socket_path is not None: - async def start_unix_socket(*_: Any) -> None: + 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 ): - await server.async_start_unix_socket() + await server.async_start_supervisor_unix_socket() else: _LOGGER.error("Supervisor user not found; not starting Unix socket") - async_when_setup_or_start(hass, "hassio", start_unix_socket) + async_when_setup_or_start(hass, "hassio", start_supervisor_unix_socket) hass.http = server @@ -394,7 +394,7 @@ def __init__( server_port: int, trusted_proxies: list[IPv4Network | IPv6Network], ssl_profile: str, - unix_socket_path: Path | None = None, + supervisor_unix_socket_path: Path | None = None, ) -> None: """Initialize the HTTP Home Assistant server.""" self.app = HomeAssistantApplication( @@ -413,10 +413,10 @@ def __init__( self.server_port = server_port self.trusted_proxies = trusted_proxies self.ssl_profile = ssl_profile - self.unix_socket_path = unix_socket_path + self.supervisor_unix_socket_path = supervisor_unix_socket_path self.runner: web.AppRunner | None = None self.site: HomeAssistantTCPSite | None = None - self.unix_site: HomeAssistantUnixSite | None = None + self.supervisor_site: HomeAssistantUnixSite | None = None self.context: ssl.SSLContext | None = None async def async_initialize( @@ -641,7 +641,7 @@ def _create_emergency_ssl_context(self) -> ssl.SSLContext: context.load_cert_chain(cert_pem.name, key_pem.name) return context - async def async_start_unix_socket(self) -> None: + async def async_start_supervisor_unix_socket(self) -> None: """Start listening on the Unix socket. This is called separately from start() to delay serving the Unix @@ -649,20 +649,24 @@ async def async_start_unix_socket(self) -> None: integration). Without this delay, Supervisor could connect before its user is available and receive 401 responses it won't retry. """ - if self.unix_socket_path is None or self.runner is None: + if self.supervisor_unix_socket_path is None or self.runner is None: return - self.unix_site = HomeAssistantUnixSite(self.runner, self.unix_socket_path) + self.supervisor_site = HomeAssistantUnixSite( + self.runner, self.supervisor_unix_socket_path + ) try: - await self.unix_site.start() + await self.supervisor_site.start() except OSError as error: _LOGGER.error( "Failed to create HTTP server on unix socket %s: %s", - self.unix_socket_path, + self.supervisor_unix_socket_path, error, ) - self.unix_site = None + self.supervisor_site = None else: - _LOGGER.info("Now listening on unix socket %s", self.unix_socket_path) + _LOGGER.info( + "Now listening on unix socket %s", self.supervisor_unix_socket_path + ) async def start(self) -> None: """Start the aiohttp server.""" @@ -691,17 +695,17 @@ async def start(self) -> None: async def stop(self) -> None: """Stop the aiohttp server.""" - if self.unix_site is not None: - await self.unix_site.stop() - if self.unix_socket_path is not None: + if self.supervisor_site is not None: + await self.supervisor_site.stop() + if self.supervisor_unix_socket_path is not None: try: await self.hass.async_add_executor_job( - self.unix_socket_path.unlink, True + self.supervisor_unix_socket_path.unlink, True ) except OSError as err: _LOGGER.warning( "Could not remove unix socket %s: %s", - self.unix_socket_path, + self.supervisor_unix_socket_path, err, ) if self.site is not None: diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5fcccfb67e860c..da097d20392f9c 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -38,7 +38,7 @@ KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER, - is_unix_socket_request, + is_supervisor_unix_socket_request, ) _LOGGER = logging.getLogger(__name__) @@ -221,7 +221,7 @@ def async_validate_signed_request(request: Request) -> bool: supervisor_user_id: str | None = None - async def async_authenticate_unix_socket(request: Request) -> bool: + 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 @@ -261,8 +261,8 @@ async def auth_middleware( """Authenticate as middleware.""" authenticated = False - if is_unix_socket_request(request): - authenticated = await async_authenticate_unix_socket(request) + if is_supervisor_unix_socket_request(request): + authenticated = await async_authenticate_supervisor_unix_socket(request) auth_type = "unix socket" elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header( diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 651065c6987918..e2ec1ad95a3139 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -30,7 +30,7 @@ from homeassistant.helpers.hassio import get_supervisor_ip, is_hassio from homeassistant.util import dt as dt_util, yaml as yaml_util -from .const import KEY_HASS, is_unix_socket_request +from .const import KEY_HASS, is_supervisor_unix_socket_request from .view import HomeAssistantView _LOGGER: Final = logging.getLogger(__name__) @@ -73,7 +73,7 @@ async def ban_middleware( ) -> StreamResponse: """IP Ban middleware.""" # Unix socket connections are trusted, skip ban checks - if is_unix_socket_request(request): + if is_supervisor_unix_socket_request(request): return await handler(request) if (ban_manager := request.app.get(KEY_BAN_MANAGER)) is None: diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index 51106bd6e1d7eb..c89751a62affcc 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,6 +1,5 @@ """HTTP specific constants.""" -import socket from typing import Final from aiohttp.web import Request @@ -13,10 +12,13 @@ KEY_HASS_REFRESH_TOKEN_ID: Final = "hass_refresh_token_id" -def is_unix_socket_request(request: Request) -> bool: - """Check if request arrived over a Unix socket.""" +def is_supervisor_unix_socket_request(request: Request) -> bool: + """Check if request arrived over the Supervisor Unix socket.""" if (transport := request.transport) is None: return False - if (sock := transport.get_extra_info("socket")) is None: + if (http := request.app[KEY_HASS].http) is None or ( + supervisor_path := http.supervisor_unix_socket_path + ) is None: return False - return bool(sock.family == socket.AF_UNIX) + sockname: str | None = transport.get_extra_info("sockname") + return sockname == str(supervisor_path) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index fffe69cd53d99e..b0e319bbce5ad4 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -69,7 +69,7 @@ def __init__( # send_bytes_text will directly send a message to the client. self._send_bytes_text = send_bytes_text - async def async_handle_unix_socket(self) -> ActiveConnection: + async def async_handle_supervisor_unix_socket(self) -> ActiveConnection: """Handle a pre-authenticated Unix socket connection.""" conn = ActiveConnection( self._logger, diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 98f0206b35d818..27280f46516a9f 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -14,7 +14,7 @@ from aiohttp.http_websocket import WebSocketWriter from homeassistant.components.http import KEY_HASS, HomeAssistantView -from homeassistant.components.http.const import is_unix_socket_request +from homeassistant.components.http.const import is_supervisor_unix_socket_request from homeassistant.const import EVENT_HOMEASSISTANT_STOP, EVENT_LOGGING_CHANGED from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -389,10 +389,10 @@ async def _async_handle_auth_phase( """Handle the auth phase of the websocket connection.""" request = self._request - if is_unix_socket_request(request): + if is_supervisor_unix_socket_request(request): # Unix socket requests are pre-authenticated by the HTTP # auth middleware — skip the token exchange. - connection = await auth.async_handle_unix_socket() + connection = await auth.async_handle_supervisor_unix_socket() else: await send_bytes_text(AUTH_REQUIRED_MESSAGE) diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 523eb24849492b..095ae8ad17a800 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -676,7 +676,8 @@ async def test_unix_socket_auth_with_supervisor_user( client = await aiohttp_client(app) with patch( - "homeassistant.components.http.auth.is_unix_socket_request", return_value=True + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, ): req = await client.get("/") assert req.status == HTTPStatus.OK @@ -694,7 +695,8 @@ async def test_unix_socket_auth_without_supervisor_user( client = await aiohttp_client(app) with patch( - "homeassistant.components.http.auth.is_unix_socket_request", return_value=True + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, ): req = await client.get("/") assert req.status == HTTPStatus.INTERNAL_SERVER_ERROR @@ -715,7 +717,8 @@ async def test_unix_socket_auth_caches_user_id( client = await aiohttp_client(app) with patch( - "homeassistant.components.http.auth.is_unix_socket_request", return_value=True + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", + return_value=True, ): # First request triggers user lookup req = await client.get("/") @@ -724,7 +727,7 @@ async def test_unix_socket_auth_caches_user_id( # Second request should use cached user ID with ( patch( - "homeassistant.components.http.auth.is_unix_socket_request", + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", return_value=True, ), patch.object( diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 0b002c59ae4098..b27c3838caf6ae 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -492,7 +492,8 @@ async def test_unix_socket_skips_ban_check( # Unix socket requests should bypass ban checks with patch( - "homeassistant.components.http.ban.is_unix_socket_request", return_value=True + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", + return_value=True, ): resp = await client.get("/") assert resp.status == HTTPStatus.NOT_FOUND diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index a76c82a45ace8a..67774d0eadd475 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -770,7 +770,7 @@ async def test_unix_socket_started_with_supervisor( mock_create_sock.assert_called_once() mock_create_unix.assert_called_once_with(ANY, sock=mock_sock, backlog=128) - assert hass.http.unix_site is not None + assert hass.http.supervisor_site is not None async def test_unix_socket_not_started_without_supervisor( @@ -786,7 +786,7 @@ async def test_unix_socket_not_started_without_supervisor( await hass.async_start() await hass.async_block_till_done() - assert hass.http.unix_site is None + assert hass.http.supervisor_site is None async def test_unix_socket_rejected_relative_path( @@ -806,5 +806,5 @@ async def test_unix_socket_rejected_relative_path( await hass.async_start() await hass.async_block_till_done() - assert hass.http.unix_site is None + assert hass.http.supervisor_site is None assert "path must be absolute" in caplog.text diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 9e0c39ecdb9b6f..09ff36c4ce02b5 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -386,15 +386,15 @@ async def test_unix_socket_auth_bypass( with ( patch( - "homeassistant.components.http.ban.is_unix_socket_request", + "homeassistant.components.http.ban.is_supervisor_unix_socket_request", return_value=True, ), patch( - "homeassistant.components.http.auth.is_unix_socket_request", + "homeassistant.components.http.auth.is_supervisor_unix_socket_request", return_value=True, ), patch( - "homeassistant.components.websocket_api.http.is_unix_socket_request", + "homeassistant.components.websocket_api.http.is_supervisor_unix_socket_request", return_value=True, ), ): From 2d677546dc9ba30fed7c9fd02f769d28a1696d84 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Mar 2026 16:50:00 +0100 Subject: [PATCH 18/21] Add Supervisor to message strings --- homeassistant/components/http/__init__.py | 5 +++-- homeassistant/components/http/auth.py | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 3e210ca391d04c..a4db676ffe38b4 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -244,7 +244,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: supervisor_unix_socket_path = socket_path else: _LOGGER.error( - "Invalid unix socket path %s: path must be absolute", socket_env + "Invalid Supervisor Unix socket path %s: path must be absolute", + socket_env, ) server = HomeAssistantHTTP( @@ -704,7 +705,7 @@ async def stop(self) -> None: ) except OSError as err: _LOGGER.warning( - "Could not remove unix socket %s: %s", + "Could not remove Supervisor unix socket %s: %s", self.supervisor_unix_socket_path, err, ) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index da097d20392f9c..dff5925ebca6ce 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -263,7 +263,7 @@ async def auth_middleware( if is_supervisor_unix_socket_request(request): authenticated = await async_authenticate_supervisor_unix_socket(request) - auth_type = "unix socket" + auth_type = "supervisor unix socket" elif hdrs.AUTHORIZATION in request.headers and async_validate_auth_header( request @@ -284,7 +284,7 @@ async def auth_middleware( if authenticated and _LOGGER.isEnabledFor(logging.DEBUG): _LOGGER.debug( "Authenticated %s for %s using %s", - request.remote or "unknown", + request.remote or "unknown remote", request.path, auth_type, ) From 8f886ae6bbcee8d7bd6b8621fc0d9e87d5124ff4 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Mar 2026 17:14:07 +0100 Subject: [PATCH 19/21] Move chmod inside of try/except block --- homeassistant/components/http/web_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/http/web_runner.py b/homeassistant/components/http/web_runner.py index f18c27c2081b5d..a28b69ba9d3a12 100644 --- a/homeassistant/components/http/web_runner.py +++ b/homeassistant/components/http/web_runner.py @@ -113,10 +113,10 @@ def _create_unix_socket(self) -> socket.socket: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: sock.bind(str(self._path)) + self._path.chmod(0o600) except OSError: sock.close() raise - self._path.chmod(0o600) return sock async def start(self) -> None: From 4bd744f30268d4ebf664d9537f0841d537ded1f9 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Mar 2026 17:20:52 +0100 Subject: [PATCH 20/21] Check for refresh token in async_sign_path Check if the refresh token is available in async_sign_path, and if not (Supervisor Unix socket request) then fallback to the content user's refresh token (a read-only system user created specifically for signing, a sensible fallback for the Supervisor case). --- homeassistant/components/http/auth.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index dff5925ebca6ce..50b3812dd7dd54 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -69,7 +69,9 @@ def async_sign_path( if refresh_token_id is None: if use_content_user: refresh_token_id = hass.data[STORAGE_KEY] - elif connection := websocket_api.current_connection.get(): + elif ( + connection := websocket_api.current_connection.get() + ) and connection.refresh_token_id: refresh_token_id = connection.refresh_token_id elif ( request := current_request.get() From d4e71ab062e06b106fa7efe09feb45e83b45a526 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Tue, 24 Mar 2026 18:20:48 +0100 Subject: [PATCH 21/21] Fix auth/delete_all_refresh_tokens endpoint for Supervisor Fix the auth/delete_all_refresh_tokens endpoint to not fail if the current token is not available when requested (which it is when the call is being made through Supervisor Unix socket). --- homeassistant/components/auth/__init__.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 27eed49e5ca503..33aeb283f5a05c 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -626,7 +626,7 @@ def websocket_delete_all_refresh_tokens( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] ) -> None: """Handle delete all refresh tokens request.""" - current_refresh_token: RefreshToken + current_refresh_token: RefreshToken | None = None remove_failed = False token_type = msg.get("token_type") delete_current_token = msg.get("delete_current_token") @@ -654,7 +654,7 @@ def websocket_delete_all_refresh_tokens( else: connection.send_result(msg["id"], {}) - async def _delete_current_token_soon() -> None: + async def _delete_current_token_soon(current_refresh_token: RefreshToken) -> None: """Delete the current token after a delay. We do not want to delete the current token immediately as it will @@ -675,13 +675,15 @@ async def _delete_current_token_soon() -> None: # the token right away. hass.auth.async_remove_refresh_token(current_refresh_token) - if delete_current_token and ( - not limit_token_types or current_refresh_token.token_type == token_type + if ( + delete_current_token + and current_refresh_token + and (not limit_token_types or current_refresh_token.token_type == token_type) ): # Deleting the token will close the connection so we need # to do it with a delay in a tracked task to ensure it still # happens if Home Assistant is shutting down. - hass.async_create_task(_delete_current_token_soon()) + hass.async_create_task(_delete_current_token_soon(current_refresh_token)) @websocket_api.websocket_command(