From 2cc2ccf2ccdef59fdd1138a967490cb953e24982 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 12 Jun 2026 11:01:51 -0400 Subject: [PATCH] sandbox: register_service ownership check (Phase 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _handle_register_service now requires the service domain to be one this group owns (same main-side _owned_domains() derivation as the fire_event gate), so a compromised sandbox can no longer squat persistent_notification.* or any unclaimed domain.service. Unowned domains are rejected with a HomeAssistantError → remote-error frame; the existing refuse-to-clobber-an-existing-handler check is kept. Existing register_service tests updated to own their mock domains via a MockConfigEntry(sandbox="built-in"). --- homeassistant/components/sandbox/bridge.py | 19 +++++++++++++++++++ tests/components/sandbox/test_bridge.py | 12 ++++++++++++ .../sandbox/test_schema_and_unload.py | 3 +++ 3 files changed, 34 insertions(+) diff --git a/homeassistant/components/sandbox/bridge.py b/homeassistant/components/sandbox/bridge.py index 05bd5295a2a0..be144e375cef 100644 --- a/homeassistant/components/sandbox/bridge.py +++ b/homeassistant/components/sandbox/bridge.py @@ -520,6 +520,13 @@ class SandboxBridge: sandbox side. Exception translation reuses :func:`_translate_remote_error`. + The service ``domain`` must be one this group owns (same main-side + :meth:`_owned_domains` derivation the fire_event gate uses): a + compromised sandbox may not squat ``persistent_notification.*`` or any + other ``domain.service`` slot outside the domains main routed to it. + An unowned domain is rejected with a :class:`HomeAssistantError` (the + channel turns it into a remote-error frame). + If a service with the same ``(domain, service)`` already exists on main (e.g. the host ``light`` EntityComponent registered ``light.turn_on`` for our proxy entities, or another integration @@ -528,6 +535,18 @@ class SandboxBridge: """ domain = msg.domain.lower() service = msg.service.lower() + if domain not in self._owned_domains(): + _LOGGER.warning( + "SandboxBridge[%s]: refusing register_service for unowned " + "domain %r (%s.%s)", + self.group, + domain, + domain, + service, + ) + raise HomeAssistantError( + f"register_service: domain {domain!r} not owned by group {self.group!r}" + ) supports_response = _parse_supports_response(msg.supports_response) if self.hass.services.has_service(domain, service): _LOGGER.debug( diff --git a/tests/components/sandbox/test_bridge.py b/tests/components/sandbox/test_bridge.py index 1905757caf4e..c8750d4ab4fc 100644 --- a/tests/components/sandbox/test_bridge.py +++ b/tests/components/sandbox/test_bridge.py @@ -539,6 +539,9 @@ async def test_register_entity_auto_loads_domain_component( async def test_register_service_installs_forwarder(hass: HomeAssistant) -> None: """A sandbox-registered service appears on main and forwards calls back.""" + MockConfigEntry( + domain="mirror_demo", title="Mirror", sandbox="built-in" + ).add_to_hass(hass) _bridge, main_channel, sandbox_channel = await _wire(hass) seen_calls: list[pb.CallService] = [] @@ -583,6 +586,9 @@ async def test_forwarded_context_restores_on_echoed_state( echoing that same context_id, main restores the *original* ``user_id`` / ``parent_id`` instead of minting a fresh attribution. """ + MockConfigEntry( + domain="mirror_demo", title="Mirror", sandbox="built-in" + ).add_to_hass(hass) _bridge, main_channel, sandbox_channel = await _wire(hass) forwarded_ids: list[str] = [] @@ -653,6 +659,9 @@ async def test_register_service_skips_existing_handler( hass: HomeAssistant, ) -> None: """Main already owning ``(domain, service)`` is not clobbered.""" + MockConfigEntry( + domain="mirror_local", title="Mirror", sandbox="built-in" + ).add_to_hass(hass) _bridge, main_channel, sandbox_channel = await _wire(hass) async def _local(_call: Any) -> None: @@ -682,6 +691,9 @@ async def test_unregister_service_removes_forwarder( hass: HomeAssistant, ) -> None: """``unregister_service`` drops the bridge-installed forwarder.""" + MockConfigEntry( + domain="mirror_demo", title="Mirror", sandbox="built-in" + ).add_to_hass(hass) _bridge, main_channel, sandbox_channel = await _wire(hass) try: diff --git a/tests/components/sandbox/test_schema_and_unload.py b/tests/components/sandbox/test_schema_and_unload.py index d9aa0e5774c0..b560eb48c266 100644 --- a/tests/components/sandbox/test_schema_and_unload.py +++ b/tests/components/sandbox/test_schema_and_unload.py @@ -257,6 +257,9 @@ async def test_register_service_with_schema_validates_on_main( hass: HomeAssistant, ) -> None: """Sandbox-mirrored service uses its reconstructed schema on main calls.""" + MockConfigEntry(domain="mock_svc", title="Mock", sandbox="built-in").add_to_hass( + hass + ) main_channel, sandbox_channel = make_channel_pair( name_a="main-mock", name_b="sandbox-mock" )