sandbox: register_service ownership check (Phase 2)

_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").
This commit is contained in:
Paulus Schoutsen
2026-07-07 15:12:24 -04:00
parent 58eb70d3c8
commit 2cc2ccf2cc
3 changed files with 34 additions and 0 deletions
@@ -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(
+12
View File
@@ -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:
@@ -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"
)