VYPR
High severity8.3NVD Advisory· Published May 29, 2026· Updated May 29, 2026

CVE-2026-44698

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
  • Home Assistant/Home Assistantinferred2 versions
    <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

4
838feef66056

Validate local_only user property during ws auth phase (#168812)

https://github.com/home-assistant/coreRobert ReschApr 22, 2026Fixed in 2026.4.4via llm-release-walk
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:
    
9621307cb046

Validate local_only user for signed requests (#169066)

https://github.com/home-assistant/coreRobert ReschApr 24, 2026Fixed in 2026.4.4via llm-release-walk
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:
    
b5e66bbcd056

2026.4.4 (#169092)

https://github.com/home-assistant/coreFranck NijhofApr 24, 2026Fixed in 2026.4.4via release-tag
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:
    
b981ece16370

Pin actions/helpers/info to fix release build (#167327)

https://github.com/home-assistant/coreFranck NijhofApr 3, 2026Fixed in 2026.4.1via release-tag
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

1

News mentions

0

No linked articles in our index yet.