CVE-2026-44698
Description
Home Assistant is open source home automation software that puts local control and privacy first. Prior to 2026.4.1 for iOS and 2026.4.4 for Android, he Home Assistant Companion apps for Android and iOS expose a JavaScript bridge to the in-app WebView window.externalApp on Android and webkit.messageHandlers.getExternalAuth (alongside revokeExternalAuth and externalBus) on iOS. Two flaws expose the bridge to all frames (including cross-origin iframes) and unsanitized interpolation of the JavaScript callback identifier allows a cross-origin iframe rendered inside the Companion app to execute arbitrary JavaScript in the Home Assistant frontend's main-frame origin and exfiltrate the signed-in user's access token. This vulnerability is fixed in 2026.4.1 for iOS and 2026.4.4 for Android.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Home Assistant Companion apps for Android/iOS expose WebView JS bridge to cross-origin iframes, enabling access token theft via unsanitized callback injection.
Vulnerability
The Home Assistant Companion apps for Android (all versions prior to 2026.4.4) and iOS (all versions prior to 2026.4.1) expose native JavaScript bridges to the in-app WebView: window.externalApp on Android via addJavascriptInterface(), and webkit.messageHandlers.getExternalAuth, revokeExternalAuth, and externalBus on iOS via WKUserContentController.add(_:name:). Two flaws combine: the bridges are accessible to all frames in the WebView (including cross-origin iframes), and the callback field of the JSON payload passed to getExternalAuth and revokeExternalAuth is interpolated verbatim into JavaScript without sanitization [1]. This allows a cross-origin iframe to inject arbitrary JavaScript into the main-frame origin and steal the signed-in user's access token.
Exploitation
An attacker needs to trick a victim using one of the affected Companion apps into visiting a page that renders a cross-origin iframe controlled by the attacker. The attacker's iframe calls window.externalApp.getExternalAuth() (Android) or window.webkit.messageHandlers.getExternalAuth.postMessage({...}) (iOS) with a crafted callback field containing malicious JavaScript. Because the bridge is exposed to all frames and the callback is unsanitized, the injected code executes in the context of the Home Assistant frontend's main frame origin, thereby exfiltrating the victim's current Home Assistant access token [1]. No further user interaction beyond loading the page is required.
Impact
An attacker who successfully exploits this vulnerability gains the victim's Home Assistant long-lived access token. With this token, the attacker can make authenticated requests to the Home Assistant REST API, potentially controlling any connected smart home devices, accessing sensitive data, and performing actions with the victim's privileges for the token's lifetime [1]. The severity is rated High (CVSS 8.3).
Mitigation
The vulnerability is fixed in Home Assistant Companion for iOS version 2026.4.1 and for Android version 2026.4.4 [1]. Users should update their Companion apps immediately to these or later versions. As of the publication date of the advisory, no workaround is documented; the fix involves restricting the JavaScript bridge access to the main frame only and properly sanitizing callback identifiers [1].
AI Insight generated on May 29, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2<2026.4.4 (Android) / <2026.4.1 (iOS)+ 1 more
- (no CPE)range: <2026.4.4 (Android) / <2026.4.1 (iOS)
- (no CPE)range: <2026.4.1
Patches
4838feef66056Validate local_only user property during ws auth phase (#168812)
4 files changed · +115 −36
homeassistant/components/http/auth.py+1 −36 modified@@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from ipaddress import ip_address import logging import secrets import time @@ -24,16 +23,14 @@ from homeassistant.auth import jwt_wrapper 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 -from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store -from homeassistant.util.network import is_local +from .auth_util import async_user_not_allowed_do_auth from .const import ( KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, @@ -99,38 +96,6 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -@callback -def async_user_not_allowed_do_auth( - hass: HomeAssistant, user: User, request: Request | None = None -) -> str | None: - """Validate that user is not allowed to do auth things.""" - if not user.is_active: - return "User is not active" - - if not user.local_only: - return None - - # User is marked as local only, check if they are allowed to do auth - if request is None: - request = current_request.get() - - if not request: - return "No request available to validate local access" - - if is_cloud_connection(hass): - return "User is local only" - - try: - remote_address = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - return "Invalid remote IP" - - if is_local(remote_address): - return None - - return "User cannot authenticate remotely" - - async def async_setup_auth( # noqa: C901 hass: HomeAssistant, app: Application,
homeassistant/components/http/auth_util.py+45 −0 added@@ -0,0 +1,45 @@ +"""Auth utilities for the HTTP component.""" + +from __future__ import annotations + +from ipaddress import ip_address + +from aiohttp.web import Request + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import current_request +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local + + +@callback +def async_user_not_allowed_do_auth( + hass: HomeAssistant, user: User, request: Request | None = None +) -> str | None: + """Validate that user is not allowed to do auth things.""" + if not user.is_active: + return "User is not active" + + if not user.local_only: + return None + + # User is marked as local only, check if they are allowed to do auth + if request is None: + request = current_request.get() + + if not request: + return "No request available to validate local access" + + if is_cloud_connection(hass): + return "User is local only" + + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return "Invalid remote IP" + + if is_local(remote_address): + return None + + return "User cannot authenticate remotely"
homeassistant/components/websocket_api/auth.py+8 −0 modified@@ -9,6 +9,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.components.http.auth_util import async_user_not_allowed_do_auth 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__ @@ -97,6 +98,13 @@ async def async_handle(self, msg: JsonValueType) -> ActiveConnection: if (access_token := valid_msg.get("access_token")) and ( refresh_token := self._hass.auth.async_validate_access_token(access_token) ): + if user_access_error := async_user_not_allowed_do_auth( + self._hass, refresh_token.user, self._request + ): + await self._send_bytes_text(auth_invalid_message(user_access_error)) + await process_wrong_login(self._request) + raise Disconnect + conn = ActiveConnection( self._logger, self._hass,
tests/components/websocket_api/test_auth.py+61 −0 modified@@ -23,6 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator @@ -151,6 +152,66 @@ async def test_auth_active_user_inactive( assert auth_msg["type"] == TYPE_AUTH_INVALID +async def test_auth_local_only_user_rejected_remote( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test that a local-only user cannot authenticate from a remote IP.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip("198.51.100.1") + + client = await hass_client_no_auth() + + with patch( + "homeassistant.components.websocket_api.auth.process_wrong_login", + ) as mock_process_wrong_login: + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_INVALID + assert auth_msg["message"] == "User cannot authenticate remotely" + + assert mock_process_wrong_login.called + + +async def test_auth_local_only_user_allowed_local( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test that a local-only user can authenticate from a local IP.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip("192.168.1.100") + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + async def test_auth_active_with_password_not_allow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None:
9621307cb046Validate local_only user for signed requests (#169066)
2 files changed · +88 −0
homeassistant/components/http/auth.py+3 −0 modified@@ -182,6 +182,9 @@ def async_validate_signed_request(request: Request) -> bool: if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True
tests/components/http/test_auth.py+85 −0 modified@@ -592,6 +592,91 @@ async def test_local_only_user_rejected( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_local_only_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for a local-only user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + set_mock_ip = mock_real_ip(app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Local IP is allowed for local-only user + set_mock_ip("192.168.1.123") + + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + # Remote IP is rejected for local-only user + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_auth_access_signed_path_with_inactive_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for an inactive user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Active user is allowed + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Inactive user is rejected + refresh_token.user.is_active = False + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_async_user_not_allowed_do_auth( hass: HomeAssistant, app: web.Application ) -> None:
b5e66bbcd0562026.4.4 (#169092)
31 files changed · +553 −123
homeassistant/components/alexa_devices/manifest.json+1 −1 modified@@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["aioamazondevices"], "quality_scale": "platinum", - "requirements": ["aioamazondevices==13.4.1"] + "requirements": ["aioamazondevices==13.4.3"] }
homeassistant/components/frontend/manifest.json+1 −1 modified@@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260325.7"] + "requirements": ["home-assistant-frontend==20260325.8"] }
homeassistant/components/gardena_bluetooth/sensor.py+4 −2 modified@@ -133,14 +133,15 @@ def context(self) -> set[str]: key=FlowStatistics.overall.unique_id, translation_key="flow_statistics_overall", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.LITERS, char=FlowStatistics.overall, ), GardenaBluetoothSensorEntityDescription( key=FlowStatistics.current.unique_id, translation_key="flow_statistics_current", + state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.VOLUME_FLOW_RATE, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, @@ -150,7 +151,7 @@ def context(self) -> set[str]: key=FlowStatistics.resettable.unique_id, translation_key="flow_statistics_resettable", state_class=SensorStateClass.TOTAL_INCREASING, - device_class=SensorDeviceClass.VOLUME, + device_class=SensorDeviceClass.WATER, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfVolume.LITERS, char=FlowStatistics.resettable, @@ -166,6 +167,7 @@ def context(self) -> set[str]: GardenaBluetoothSensorEntityDescription( key=Spray.current_distance.unique_id, translation_key="spray_current_distance", + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, char=Spray.current_distance,
homeassistant/components/google_generative_ai_conversation/helpers.py+3 −3 modified@@ -49,7 +49,7 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: integers if found, otherwise None. """ - if not mime_type.startswith("audio/L"): + if not mime_type.lower().startswith("audio/l"): LOGGER.warning("Received unexpected MIME type %s", mime_type) raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}") @@ -65,9 +65,9 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]: with suppress(ValueError, IndexError): rate_str = param.split("=", 1)[1] rate = int(rate_str) - elif param.startswith("audio/L"): + elif param.lower().startswith("audio/l"): # Keep bits_per_sample as default if conversion fails with suppress(ValueError, IndexError): - bits_per_sample = int(param.split("L", 1)[1]) + bits_per_sample = int(param.upper().split("L", 1)[1]) return {"bits_per_sample": bits_per_sample, "rate": rate}
homeassistant/components/hive/manifest.json+1 −1 modified@@ -10,5 +10,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["apyhiveapi"], - "requirements": ["pyhive-integration==1.0.8"] + "requirements": ["pyhive-integration==1.0.9"] }
homeassistant/components/http/auth.py+4 −36 modified@@ -4,7 +4,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from ipaddress import ip_address import logging import secrets import time @@ -24,16 +23,14 @@ from homeassistant.auth import jwt_wrapper 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 -from homeassistant.helpers.network import is_cloud_connection from homeassistant.helpers.storage import Store -from homeassistant.util.network import is_local +from .auth_util import async_user_not_allowed_do_auth from .const import ( KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, @@ -99,38 +96,6 @@ def async_sign_path( return f"{url.path}?{url.query_string}" -@callback -def async_user_not_allowed_do_auth( - hass: HomeAssistant, user: User, request: Request | None = None -) -> str | None: - """Validate that user is not allowed to do auth things.""" - if not user.is_active: - return "User is not active" - - if not user.local_only: - return None - - # User is marked as local only, check if they are allowed to do auth - if request is None: - request = current_request.get() - - if not request: - return "No request available to validate local access" - - if is_cloud_connection(hass): - return "User is local only" - - try: - remote_address = ip_address(request.remote) # type: ignore[arg-type] - except ValueError: - return "Invalid remote IP" - - if is_local(remote_address): - return None - - return "User cannot authenticate remotely" - - async def async_setup_auth( # noqa: C901 hass: HomeAssistant, app: Application, @@ -217,6 +182,9 @@ def async_validate_signed_request(request: Request) -> bool: if refresh_token is None: return False + if async_user_not_allowed_do_auth(hass, refresh_token.user, request): + return False + request[KEY_HASS_USER] = refresh_token.user request[KEY_HASS_REFRESH_TOKEN_ID] = refresh_token.id return True
homeassistant/components/http/auth_util.py+45 −0 added@@ -0,0 +1,45 @@ +"""Auth utilities for the HTTP component.""" + +from __future__ import annotations + +from ipaddress import ip_address + +from aiohttp.web import Request + +from homeassistant.auth.models import User +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.http import current_request +from homeassistant.helpers.network import is_cloud_connection +from homeassistant.util.network import is_local + + +@callback +def async_user_not_allowed_do_auth( + hass: HomeAssistant, user: User, request: Request | None = None +) -> str | None: + """Validate that user is not allowed to do auth things.""" + if not user.is_active: + return "User is not active" + + if not user.local_only: + return None + + # User is marked as local only, check if they are allowed to do auth + if request is None: + request = current_request.get() + + if not request: + return "No request available to validate local access" + + if is_cloud_connection(hass): + return "User is local only" + + try: + remote_address = ip_address(request.remote) # type: ignore[arg-type] + except ValueError: + return "Invalid remote IP" + + if is_local(remote_address): + return None + + return "User cannot authenticate remotely"
homeassistant/components/imap/coordinator.py+21 −1 modified@@ -494,6 +494,7 @@ async def async_start(self) -> None: async def _async_wait_push_loop(self) -> None: """Wait for data push from server.""" + idle: asyncio.Future | None = None while True: try: self.number_of_messages = await self._async_fetch_number_of_messages() @@ -527,8 +528,9 @@ async def _async_wait_push_loop(self) -> None: else: self.auth_errors = 0 self.async_set_updated_data(self.number_of_messages) + try: - idle: asyncio.Future = await self.imap_client.idle_start() + idle = await self.imap_client.idle_start() await self.imap_client.wait_server_push() self.imap_client.idle_done() async with asyncio.timeout(10): @@ -543,6 +545,24 @@ async def _async_wait_push_loop(self) -> None: await self._cleanup() await asyncio.sleep(BACKOFF_TIME) + finally: + # Ensure no pending IDLE future survives + if idle is not None and not idle.done(): + idle.cancel() + _LOGGER.debug( + "Canceling IDLE wait for %s", + self.config_entry.data[CONF_SERVER], + ) + try: + await idle + except asyncio.CancelledError: + if ( + current_task := asyncio.current_task() + ) and current_task.cancelling(): + raise + except AioImapException: + pass + async def shutdown(self, *_: Any) -> None: """Close resources.""" if self._push_wait_task:
homeassistant/components/kodi/browse_media.py+1 −1 modified@@ -70,7 +70,7 @@ async def build_item_response(media_library, payload, get_thumbnail_url=None): media_content_id=search_id, media_content_type=search_type, title=title, - can_play=search_type in PLAYABLE_MEDIA_TYPES and search_id, + can_play=bool(search_type in PLAYABLE_MEDIA_TYPES and search_id), can_expand=True, children=children, thumbnail=thumbnail,
homeassistant/components/mqtt/light/schema_json.py+2 −2 modified@@ -337,8 +337,8 @@ async def _subscribe_topics(self) -> None: self._attr_brightness = last_attributes.get( ATTR_BRIGHTNESS, self.brightness ) - self._attr_color_mode = last_attributes.get( - ATTR_COLOR_MODE, self.color_mode + self._attr_color_mode = ( + last_attributes.get(ATTR_COLOR_MODE) or self.color_mode ) self._attr_color_temp_kelvin = last_attributes.get( ATTR_COLOR_TEMP_KELVIN, self.color_temp_kelvin
homeassistant/components/roborock/vacuum.py+21 −11 modified@@ -240,14 +240,16 @@ async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: translation_domain=DOMAIN, translation_key="update_options_failed", ) - await self.send( - RoborockCommand.SET_CUSTOM_MODE, - [ - {v: k for k, v in self._status_trait.fan_speed_mapping.items()}[ - fan_speed - ] - ], - ) + code_mapping = {v: k for k, v in self._status_trait.fan_speed_mapping.items()} + if (fan_speed_code := code_mapping.get(fan_speed)) is None: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) + await self.send(RoborockCommand.SET_CUSTOM_MODE, [fan_speed_code]) async def async_set_vacuum_goto_position(self, x: int, y: int) -> None: """Send vacuum to a specific target point.""" @@ -458,9 +460,17 @@ async def async_locate(self, **kwargs: Any) -> None: async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None: """Set vacuum fan speed.""" try: - await self.coordinator.api.set_fan_speed( - SCWindMapping.from_value(fan_speed) - ) + fan_speed_code = SCWindMapping.from_value(fan_speed) + except ValueError as err: + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="invalid_fan_speed", + translation_placeholders={ + "fan_speed": fan_speed, + }, + ) from err + try: + await self.coordinator.api.set_fan_speed(fan_speed_code) except RoborockException as err: raise HomeAssistantError( translation_domain=DOMAIN,
homeassistant/components/tibber/manifest.json+1 −1 modified@@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.1"] + "requirements": ["pyTibber==0.37.2"] }
homeassistant/components/tractive/__init__.py+15 −10 modified@@ -102,13 +102,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> tractive = TractiveClient(hass, client, creds["user_id"], entry) + trackables = [] try: - trackable_objects = await client.trackable_objects() - trackables = await asyncio.gather( - *(_generate_trackables(client, item) for item in trackable_objects) - ) + for obj in await client.trackable_objects(): + # To avoid hitting Tractive API rate limits, we add a small + # delay between requests to fetch trackable details. + await asyncio.sleep(2) + trackables.append(await _generate_trackables(client, obj)) except aiotractive.exceptions.TractiveError as error: + await client.close() raise ConfigEntryNotReady from error + except ConfigEntryNotReady: + await client.close() + raise # When the pet defined in Tractive has no tracker linked we get None as `trackable`. # So we have to remove None values from trackables list. @@ -164,12 +170,11 @@ async def _generate_trackables( tracker = client.tracker(trackable_data["device_id"]) trackable_pet = client.trackable_object(trackable_data["_id"]) - tracker_details, hw_info, pos_report, health_overview = await asyncio.gather( - tracker.details(), - tracker.hw_info(), - tracker.pos_report(), - trackable_pet.health_overview(), - ) + # Sequential fetching to prevent HTTP 429 Rate Limits + tracker_details = await tracker.details() + hw_info = await tracker.hw_info() + pos_report = await tracker.pos_report() + health_overview = await trackable_pet.health_overview() if not tracker_details.get("_id"): raise ConfigEntryNotReady(
homeassistant/components/tractive/manifest.json+1 −1 modified@@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==1.0.2"] + "requirements": ["aiotractive==1.0.3"] }
homeassistant/components/victron_ble/__init__.py+26 −16 modified@@ -3,9 +3,11 @@ from __future__ import annotations import logging +from struct import error as struct_error from sensor_state_data import SensorUpdate from victron_ble_ha_parser import VictronBluetoothDeviceData +from victron_ble_ha_parser.parser import detect_device_type from homeassistant.components.bluetooth import ( BluetoothScanningMode, @@ -38,25 +40,33 @@ def _update( nonlocal consecutive_failures update = data.update(service_info) - # Only consider a reauth when the device type is recognised (devices - # populated) but the advertisement key fails the quick-check built into - # validate_advertisement_key. Using the key check instead of counting - # entity values avoids false positives: some devices legitimately return - # few (or zero) sensor values when in certain error or alarm states. + # Only assess key validity for instant-readout advertisements + # (0x10 prefix) whose device type the parser actually recognizes. + # Unrecognized mode bytes or non-instant-readout packets are neutral: + # they say nothing about whether the encryption key is correct, so + # they must not increment or reset the failure counter. raw_data = service_info.manufacturer_data.get(VICTRON_IDENTIFIER) if update.devices and raw_data is not None: - if not data.validate_advertisement_key(raw_data): - consecutive_failures += 1 - if consecutive_failures >= REAUTH_AFTER_FAILURES: - _LOGGER.debug( - "Triggering reauth for %s after %d consecutive failures", - address, - consecutive_failures, - ) - entry.async_start_reauth(hass) + try: + is_recognizable = ( + raw_data[:1] == b"\x10" and detect_device_type(raw_data) is not None + ) + except struct_error, IndexError: + is_recognizable = False + + if is_recognizable: + if not data.validate_advertisement_key(raw_data): + consecutive_failures += 1 + if consecutive_failures >= REAUTH_AFTER_FAILURES: + _LOGGER.debug( + "Triggering reauth for %s after %d consecutive failures", + address, + consecutive_failures, + ) + entry.async_start_reauth(hass) + consecutive_failures = 0 + else: consecutive_failures = 0 - else: - consecutive_failures = 0 else: consecutive_failures = 0
homeassistant/components/websocket_api/auth.py+8 −0 modified@@ -9,6 +9,7 @@ import voluptuous as vol from voluptuous.humanize import humanize_error +from homeassistant.components.http.auth_util import async_user_not_allowed_do_auth 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__ @@ -97,6 +98,13 @@ async def async_handle(self, msg: JsonValueType) -> ActiveConnection: if (access_token := valid_msg.get("access_token")) and ( refresh_token := self._hass.auth.async_validate_access_token(access_token) ): + if user_access_error := async_user_not_allowed_do_auth( + self._hass, refresh_token.user, self._request + ): + await self._send_bytes_text(auth_invalid_message(user_access_error)) + await process_wrong_login(self._request) + raise Disconnect + conn = ActiveConnection( self._logger, self._hass,
homeassistant/const.py+1 −1 modified@@ -17,7 +17,7 @@ APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2026 MINOR_VERSION: Final = 4 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 14, 2)
homeassistant/package_constraints.txt+1 −1 modified@@ -39,7 +39,7 @@ habluetooth==5.11.1 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0
pyproject.toml+1 −1 modified@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2026.4.3" +version = "2026.4.4" license = "Apache-2.0" license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"] description = "Open-source home automation platform running on Python 3."
requirements_all.txt+5 −5 modified@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.4.1 +aioamazondevices==13.4.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -425,7 +425,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.2 +aiotractive==1.0.3 # homeassistant.components.unifi aiounifi==88 @@ -1229,7 +1229,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 @@ -1925,7 +1925,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.37.1 +pyTibber==0.37.2 # homeassistant.components.dlink pyW215==0.8.0 @@ -2155,7 +2155,7 @@ pyhaversion==22.8.0 pyheos==1.0.6 # homeassistant.components.hive -pyhive-integration==1.0.8 +pyhive-integration==1.0.9 # homeassistant.components.homematic pyhomematic==0.1.77
requirements_test_all.txt+5 −5 modified@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2 aioairzone==1.0.5 # homeassistant.components.alexa_devices -aioamazondevices==13.4.1 +aioamazondevices==13.4.3 # homeassistant.components.ambient_network # homeassistant.components.ambient_station @@ -410,7 +410,7 @@ aiotankerkoenig==0.5.1 aiotedee==0.3.0 # homeassistant.components.tractive -aiotractive==1.0.2 +aiotractive==1.0.3 # homeassistant.components.unifi aiounifi==88 @@ -1093,7 +1093,7 @@ hole==0.9.0 holidays==0.94 # homeassistant.components.frontend -home-assistant-frontend==20260325.7 +home-assistant-frontend==20260325.8 # homeassistant.components.conversation home-assistant-intents==2026.3.24 @@ -1668,7 +1668,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.37.1 +pyTibber==0.37.2 # homeassistant.components.dlink pyW215==0.8.0 @@ -1847,7 +1847,7 @@ pyhaversion==22.8.0 pyheos==1.0.6 # homeassistant.components.hive -pyhive-integration==1.0.8 +pyhive-integration==1.0.9 # homeassistant.components.homematic pyhomematic==0.1.77
tests/components/gardena_bluetooth/snapshots/test_sensor.ambr+10 −4 modified@@ -92,7 +92,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), 'config_entry_id': <ANY>, 'config_subentry_id': <ANY>, 'device_class': None, @@ -127,6 +129,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Mock Title Current distance', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, 'unit_of_measurement': '%', }), 'context': <ANY>, @@ -143,7 +146,9 @@ None, ]), 'area_id': None, - 'capabilities': None, + 'capabilities': dict({ + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, + }), 'config_entry_id': <ANY>, 'config_subentry_id': <ANY>, 'device_class': None, @@ -182,6 +187,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'volume_flow_rate', 'friendly_name': 'Mock Title Current flow', + 'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>, 'unit_of_measurement': <UnitOfVolumeFlowRate.LITERS_PER_MINUTE: 'L/min'>, }), 'context': <ANY>, @@ -399,7 +405,7 @@ 'suggested_display_precision': 2, }), }), - 'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>, + 'original_device_class': <SensorDeviceClass.WATER: 'water'>, 'original_icon': None, 'original_name': 'Overall flow', 'platform': 'gardena_bluetooth', @@ -414,7 +420,7 @@ # name: test_sensors[aqua_contour][sensor.mock_title_overall_flow-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'volume', + 'device_class': 'water', 'friendly_name': 'Mock Title Overall flow', 'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>, 'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
tests/components/google_generative_ai_conversation/test_helpers.py+28 −0 added@@ -0,0 +1,28 @@ +"""Tests for the Google Generative AI Conversation helpers.""" + +from __future__ import annotations + +import pytest + +from homeassistant.components.google_generative_ai_conversation.helpers import ( + _parse_audio_mime_type, +) +from homeassistant.exceptions import HomeAssistantError + + +def test_parse_audio_mime_type_uppercase() -> None: + """Test parsing uppercase MIME type audio/L16;rate=24000.""" + result = _parse_audio_mime_type("audio/L16;rate=24000") + assert result == {"bits_per_sample": 16, "rate": 24000} + + +def test_parse_audio_mime_type_lowercase() -> None: + """Test parsing lowercase MIME type audio/l16; rate=24000; channels=1.""" + result = _parse_audio_mime_type("audio/l16; rate=24000; channels=1") + assert result == {"bits_per_sample": 16, "rate": 24000} + + +def test_parse_audio_mime_type_unsupported_raises() -> None: + """Test that an unsupported MIME type raises HomeAssistantError.""" + with pytest.raises(HomeAssistantError): + _parse_audio_mime_type("video/mp4")
tests/components/http/test_auth.py+85 −0 modified@@ -592,6 +592,91 @@ async def test_local_only_user_rejected( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_local_only_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for a local-only user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + set_mock_ip = mock_real_ip(app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Local IP is allowed for local-only user + set_mock_ip("192.168.1.123") + + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + # Remote IP is rejected for local-only user + for remote_addr in EXTERNAL_ADDRESSES: + set_mock_ip(remote_addr) + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + +async def test_auth_access_signed_path_with_inactive_user( + hass: HomeAssistant, + app: web.Application, + aiohttp_client: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test access with signed url for an inactive user.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Active user is allowed + req = await client.head(signed_path) + assert req.status == HTTPStatus.OK + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + signed_path = async_sign_path( + hass, "/", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + # Inactive user is rejected + refresh_token.user.is_active = False + + req = await client.head(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + req = await client.get(signed_path) + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_async_user_not_allowed_do_auth( hass: HomeAssistant, app: web.Application ) -> None:
tests/components/imap/test_init.py+5 −0 modified@@ -538,6 +538,7 @@ async def test_lost_connection_with_imap_push( ) -> None: """Test error handling when the connection is lost.""" # Mock an error in waiting for a pushed update + mock_imap_protocol.idle_start.return_value = asyncio.Future() mock_imap_protocol.wait_server_push.side_effect = imap_wait_server_push_exception config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) config_entry.add_to_hass(hass) @@ -550,6 +551,10 @@ async def test_lost_connection_with_imap_push( assert state is not None assert state.state == "0" + async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) + await hass.async_block_till_done() + assert "Canceling IDLE wait for imap.server.com" in caplog.text + @pytest.mark.parametrize("imap_has_capability", [True], ids=["push"]) async def test_fetch_number_of_messages(
tests/components/mqtt/test_light_json.py+57 −0 modified@@ -565,6 +565,63 @@ async def test_single_color_mode_turn_on( assert state.state == STATE_ON +@pytest.mark.parametrize( + "hass_config", + [ + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "supported_color_modes": ["brightness"], + } + } + }, + { + mqtt.DOMAIN: { + light.DOMAIN: { + "schema": "json", + "name": "test", + "command_topic": "test_light/set", + "supported_color_modes": ["color_temp"], + } + } + }, + ], +) +async def test_restore_state_with_none_color_mode( + hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator +) -> None: + """Test restoring state with a None color_mode does not break turn_on. + + Regression test: When an optimistic light was off at the time the state + was last saved, `state_attributes` stores `color_mode: None`. On restart, + the restore path would overwrite the correctly initialized color_mode with + None, causing turn_on to raise "does not report a color mode". + """ + fake_state = State( + "light.test", + STATE_OFF, + {"color_mode": None, "brightness": None}, + ) + mock_restore_cache(hass, (fake_state,)) + + mqtt_mock = await mqtt_mock_entry() + + state = hass.states.get("light.test") + assert state.state == STATE_OFF + + # This should not raise "does not report a color mode" + await common.async_turn_on(hass, "light.test") + mqtt_mock.async_publish.assert_called_once_with( + "test_light/set", '{"state":"ON"}', 0, False + ) + state = hass.states.get("light.test") + assert state.state == STATE_ON + assert state.attributes.get("color_mode") is not None + + @pytest.mark.parametrize( "hass_config", [
tests/components/roborock/test_vacuum.py+23 −19 modified@@ -142,6 +142,29 @@ async def test_commands( assert vacuum_command.send.call_args == call(command, params=called_params) +@pytest.mark.parametrize( + "entity_id", + [ + ENTITY_ID, + Q7_ENTITY_ID, + Q10_ENTITY_ID, + ], +) +async def test_set_fan_speed_invalid( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test calling set_fan_speed with an invalid mode.""" + with pytest.raises(ServiceValidationError, match="Invalid fan speed: some-mode"): + await hass.services.async_call( + VACUUM_DOMAIN, + SERVICE_SET_FAN_SPEED, + {ATTR_ENTITY_ID: entity_id, "fan_speed": "some-mode"}, + blocking=True, + ) + + @pytest.mark.parametrize( ("in_cleaning_int", "in_returning_int", "expected_command"), [ @@ -880,25 +903,6 @@ async def test_q10_set_fan_speed_command( assert q10_vacuum_api.vacuum.set_fan_level.call_args[0] == (YXFanLevel.QUIET,) -async def test_q10_set_invalid_fan_speed( - hass: HomeAssistant, - setup_entry: MockConfigEntry, - q10_vacuum_api: Mock, -) -> None: - """Test that setting an invalid fan speed raises an error.""" - vacuum = hass.states.get(Q10_ENTITY_ID) - assert vacuum - - with pytest.raises(ServiceValidationError): - await hass.services.async_call( - VACUUM_DOMAIN, - SERVICE_SET_FAN_SPEED, - {ATTR_ENTITY_ID: Q10_ENTITY_ID, "fan_speed": "invalid_speed"}, - blocking=True, - ) - assert q10_vacuum_api.vacuum.set_fan_level.call_count == 0 - - @pytest.mark.parametrize( "command", [
tests/components/tractive/conftest.py+4 −0 modified@@ -89,6 +89,10 @@ def send_server_unavailable_event(hass: HomeAssistant) -> None: patch( "homeassistant.components.tractive.aiotractive.Tractive", autospec=True ) as mock_client, + patch( + "homeassistant.components.tractive.asyncio.sleep", + new_callable=AsyncMock, + ), ): client = mock_client.return_value client.authenticate.return_value = {"user_id": "12345"}
tests/components/victron_ble/fixtures.py+16 −0 modified@@ -201,6 +201,22 @@ source="local", ) +# Same Victron manufacturer data prefix but with an unrecognized mode byte +# (0xEE at offset 4). detect_device_type returns None for this payload, +# so validate_advertisement_key would also return False. The reauth logic +# must treat this as neutral (not a key failure). +VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO = BluetoothServiceInfo( + name="Inverter Charger", + address="01:02:03:04:05:06", + rssi=-60, + manufacturer_data={ + 0x02E1: bytes.fromhex("10038027ee1252dad26f0b8eb39162074d140df410") + }, + service_data={}, + service_uuids=[], + source="local", +) + VICTRON_VEBUS_SENSORS = { "inverter_charger_device_state": "float", "inverter_charger_battery_voltage": "14.45",
tests/components/victron_ble/test_sensor.py+96 −0 modified@@ -41,6 +41,7 @@ VICTRON_VEBUS_BAD_KEY_SERVICE_INFO, VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN, + VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO, ) from tests.common import MockConfigEntry, snapshot_platform @@ -165,6 +166,28 @@ def _inject_bad_advertisement(hass: HomeAssistant, seq: int = 0) -> None: ) +def _inject_unrecognized_mode_advertisement(hass: HomeAssistant, seq: int = 0) -> None: + """Inject a Victron advertisement with an unrecognized mode byte. + + detect_device_type returns None for this payload so the reauth guard + must treat it as neutral (neither increment nor reset the failure counter). + """ + info = VICTRON_VEBUS_UNRECOGNIZED_MODE_SERVICE_INFO + raw = bytearray(info.manufacturer_data[VICTRON_IDENTIFIER]) + raw[-1] = seq & 0xFF + device = generate_ble_device(address=info.address, name=info.name, details={}) + adv = generate_advertisement_data( + local_name=info.name, + manufacturer_data={VICTRON_IDENTIFIER: bytes(raw)}, + service_data=info.service_data, + service_uuids=info.service_uuids, + rssi=-60, + ) + inject_advertisement_with_time_and_source_connectable( + hass, device, adv, time.monotonic(), "local", True + ) + + @pytest.mark.usefixtures("enable_bluetooth") async def test_reauth_triggered_after_consecutive_failures( hass: HomeAssistant, @@ -323,3 +346,76 @@ async def test_charger_error_state( state = hass.states.get("sensor.solar_charger_charger_error") assert state is not None assert state.state == expected_state + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_reauth_not_triggered_on_unrecognized_mode( + hass: HomeAssistant, + mock_config_entry_added_to_hass: MockConfigEntry, +) -> None: + """Test reauth is NOT triggered by advertisements with unrecognized mode bytes. + + Some Victron devices broadcast advertisements with mode bytes that + detect_device_type does not recognize (returns None). + validate_advertisement_key also returns False for these, but that does + not mean the encryption key is wrong. + + Regression test for https://github.com/home-assistant/core/issues/168019 + """ + entry = mock_config_entry_added_to_hass + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # First inject a valid advertisement so update.devices is populated + inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO) + await hass.async_block_till_done() + + # Now send many unrecognized-mode advertisements + for i in range(REAUTH_AFTER_FAILURES + 5): + _inject_unrecognized_mode_advertisement(hass, seq=i) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 0 + + +@pytest.mark.usefixtures("enable_bluetooth") +async def test_reauth_still_triggers_across_unrecognized_mode( + hass: HomeAssistant, + mock_config_entry_added_to_hass: MockConfigEntry, +) -> None: + """Test that unrecognized-mode advertisements are neutral for the failure counter. + + The sequence bad → bad → unrecognized → bad must still trigger reauth + because unrecognized advertisements should neither increment nor reset the + consecutive failure counter. + + Regression test for https://github.com/home-assistant/core/issues/168019 + """ + entry = mock_config_entry_added_to_hass + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # First inject a valid advertisement so update.devices is populated + inject_bluetooth_service_info(hass, VICTRON_VEBUS_SERVICE_INFO) + await hass.async_block_till_done() + + # bad, bad (2 failures) + _inject_bad_advertisement(hass, seq=100) + await hass.async_block_till_done() + _inject_bad_advertisement(hass, seq=101) + await hass.async_block_till_done() + + # unrecognized mode — should be neutral + _inject_unrecognized_mode_advertisement(hass, seq=50) + await hass.async_block_till_done() + + # one more bad → 3 consecutive failures → reauth + _inject_bad_advertisement(hass, seq=102) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress_by_handler(DOMAIN) + assert len(flows) == 1 + assert flows[0]["context"]["source"] == "reauth"
tests/components/websocket_api/test_auth.py+61 −0 modified@@ -23,6 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +from tests.test_util import mock_real_ip from tests.typing import ClientSessionGenerator @@ -151,6 +152,66 @@ async def test_auth_active_user_inactive( assert auth_msg["type"] == TYPE_AUTH_INVALID +async def test_auth_local_only_user_rejected_remote( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test that a local-only user cannot authenticate from a remote IP.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip("198.51.100.1") + + client = await hass_client_no_auth() + + with patch( + "homeassistant.components.websocket_api.auth.process_wrong_login", + ) as mock_process_wrong_login: + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_INVALID + assert auth_msg["message"] == "User cannot authenticate remotely" + + assert mock_process_wrong_login.called + + +async def test_auth_local_only_user_allowed_local( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + hass_access_token: str, +) -> None: + """Test that a local-only user can authenticate from a local IP.""" + refresh_token = hass.auth.async_validate_access_token(hass_access_token) + refresh_token.user.local_only = True + + assert await async_setup_component(hass, "websocket_api", {}) + await hass.async_block_till_done() + + set_mock_ip = mock_real_ip(hass.http.app) + set_mock_ip("192.168.1.100") + + client = await hass_client_no_auth() + + async with client.ws_connect(URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_REQUIRED + + await ws.send_json({"type": TYPE_AUTH, "access_token": hass_access_token}) + + auth_msg = await ws.receive_json() + assert auth_msg["type"] == TYPE_AUTH_OK + + async def test_auth_active_with_password_not_allow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator ) -> None:
b981ece16370Pin actions/helpers/info to fix release build (#167327)
1 file changed · +1 −1
.github/workflows/builder.yml+1 −1 modified@@ -49,7 +49,7 @@ jobs: - name: Get information id: info - uses: home-assistant/actions/helpers/info@master # zizmor: ignore[unpinned-uses] + uses: home-assistant/actions/helpers/info@5f5b077d63a1e4c53019231409a0c4d791fb74e5 # zizmor: ignore[unpinned-uses] - name: Get version id: version
Vulnerability mechanics
Root cause
"The JavaScript bridge in the Companion apps is exposed to all frames (including cross-origin iframes) and unsanitized interpolation of the callback identifier allows arbitrary JavaScript execution."
Attack vector
An attacker hosting a malicious website can embed a cross-origin iframe that loads inside the Home Assistant Companion app's WebView. Because the JavaScript bridge is exposed to all frames without origin checks, the iframe can invoke the bridge handlers. Unsanitized interpolation of the callback identifier in the bridge response allows the attacker to inject arbitrary JavaScript into the Home Assistant frontend's main-frame origin, exfiltrating the signed-in user's access token. The CVSS vector indicates the attack requires user interaction (the victim must visit the attacker's page) and high attack complexity.
Affected code
The vulnerability resides in the JavaScript bridge exposed by the Home Assistant Companion apps for Android and iOS. On Android the bridge is `window.externalApp`; on iOS it is `webkit.messageHandlers.getExternalAuth`, `revokeExternalAuth`, and `externalBus`. The bridge is accessible to all frames (including cross-origin iframes) and unsanitized interpolation of the JavaScript callback identifier allows arbitrary code execution.
What the fix does
The patches shown in the bundle do not directly address the JavaScript bridge vulnerability; they are unrelated changes (Victron BLE reauth logic, HTTP auth utilities, Roborock fan speed validation, etc.). The advisory states the fix is in Companion app versions 2026.4.1 (iOS) and 2026.4.4 (Android), but no patch diff for the bridge code is included in this bundle. The advisory does not specify the exact code change, only that the bridge exposure and callback interpolation were fixed.
Preconditions
- authThe victim must be signed into Home Assistant through the Companion app.
- inputThe victim must navigate to a page controlled by the attacker (e.g., via a malicious link or ad) that loads a cross-origin iframe.
- configThe Companion app must be a version prior to 2026.4.1 (iOS) or 2026.4.4 (Android).
Generated on May 29, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
1News mentions
0No linked articles in our index yet.