From 9b53a3f24944991a60791bf5cba17587638f65fd Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 16 May 2026 13:57:25 +0000 Subject: [PATCH 1/2] Add-on iframe: delegate microphone + camera Permissions Policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The add-on ingress iframe in ``ha-panel-app.ts`` ships without an ``allow=`` attribute, so the Permissions Policy default of *deny* applies for ``microphone`` and ``camera`` on the cross-origin iframe. An add-on that wants to call ``getUserMedia`` — voice notes, dictation, video calls, photo capture — fails silently with ``NotAllowedError`` before the browser even surfaces the permission prompt. The failure is most visible on the Android Companion app, where there's no "open in a new tab" escape: the user presses the mic button and nothing happens, no toast, no logs. Delegate ``microphone``, ``camera``, and ``clipboard-write`` to the add-on iframe. Add-ons are first-party software the user explicitly installs, and Chrome's runtime permission prompt still gates the hardware access — the ``allow=`` attribute just lets the iframe *request* the prompt instead of being blocked at the policy layer. ``clipboard-write`` is bundled in because the next-most-frequent silent-fail in add-on land is ``navigator.clipboard.writeText`` for "copy link" / "copy code" affordances, blocked by the same mechanism. --- src/panels/app/ha-panel-app.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/panels/app/ha-panel-app.ts b/src/panels/app/ha-panel-app.ts index 6cf845ce92cc..e25b8d60db41 100644 --- a/src/panels/app/ha-panel-app.ts +++ b/src/panels/app/ha-panel-app.ts @@ -136,6 +136,7 @@ class HaPanelApp extends LitElement { })} title=${this._addon.name} src=${this._addon.ingress_url!} + allow="microphone; camera; clipboard-write" @load=${this._checkLoaded} ${ref(this._iframeRef)} > From 8abdad92785dd59c128ffdb146e96f62cfa96d70 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 18 May 2026 12:18:04 -0400 Subject: [PATCH 2/2] Sandbox add-on ingress iframe without allow-same-origin Split IFRAME_SANDBOX into two constants: IFRAME_SANDBOX (without allow-same-origin) for add-on ingress iframes that need origin isolation, and IFRAME_SANDBOX_SAME_ORIGIN for external iframes that need same-origin access. This ensures add-on iframes can't inherit camera/microphone permissions already granted to the Home Assistant origin, and prevents same-origin iframes from removing their own sandbox. Co-Authored-By: Claude Opus 4.6 --- src/panels/app/ha-panel-app.ts | 2 ++ src/panels/iframe/ha-panel-iframe.ts | 4 ++-- src/panels/lovelace/cards/hui-iframe-card.ts | 4 ++-- src/util/iframe.ts | 4 +++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/panels/app/ha-panel-app.ts b/src/panels/app/ha-panel-app.ts index e25b8d60db41..027a229a6232 100644 --- a/src/panels/app/ha-panel-app.ts +++ b/src/panels/app/ha-panel-app.ts @@ -6,6 +6,7 @@ import { classMap } from "lit/directives/class-map"; import { createRef, ref } from "lit/directives/ref"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; +import { IFRAME_SANDBOX } from "../../util/iframe"; import { navigate } from "../../common/navigate"; import { computeRouteTail } from "../../common/url/route"; import { nextRender } from "../../common/util/render-status"; @@ -136,6 +137,7 @@ class HaPanelApp extends LitElement { })} title=${this._addon.name} src=${this._addon.ingress_url!} + .sandbox=${IFRAME_SANDBOX} allow="microphone; camera; clipboard-write" @load=${this._checkLoaded} ${ref(this._iframeRef)} diff --git a/src/panels/iframe/ha-panel-iframe.ts b/src/panels/iframe/ha-panel-iframe.ts index aa29d29dee99..38618632a223 100644 --- a/src/panels/iframe/ha-panel-iframe.ts +++ b/src/panels/iframe/ha-panel-iframe.ts @@ -4,7 +4,7 @@ import { ifDefined } from "lit/directives/if-defined"; import "../../layouts/hass-error-screen"; import "../../layouts/hass-subpage"; import type { HomeAssistant, PanelInfo } from "../../types"; -import { IFRAME_SANDBOX } from "../../util/iframe"; +import { IFRAME_SANDBOX_SAME_ORIGIN } from "../../util/iframe"; @customElement("ha-panel-iframe") class HaPanelIframe extends LitElement { @@ -41,7 +41,7 @@ class HaPanelIframe extends LitElement { this.panel.title === null ? undefined : this.panel.title )} src=${this.panel.config.url} - .sandbox=${IFRAME_SANDBOX} + .sandbox=${IFRAME_SANDBOX_SAME_ORIGIN} allow="fullscreen" > diff --git a/src/panels/lovelace/cards/hui-iframe-card.ts b/src/panels/lovelace/cards/hui-iframe-card.ts index fa0ce0d93261..0ed2c86db4ce 100644 --- a/src/panels/lovelace/cards/hui-iframe-card.ts +++ b/src/panels/lovelace/cards/hui-iframe-card.ts @@ -13,7 +13,7 @@ import type { LovelaceGridOptions, } from "../types"; import type { IframeCardConfig } from "./types"; -import { IFRAME_SANDBOX } from "../../../util/iframe"; +import { IFRAME_SANDBOX_SAME_ORIGIN } from "../../../util/iframe"; @customElement("hui-iframe-card") export class HuiIframeCard extends LitElement implements LovelaceCard { @@ -95,7 +95,7 @@ export class HuiIframeCard extends LitElement implements LovelaceCard { } const sandbox_params = this._config.disable_sandbox ? undefined - : `${sandbox_user_params} ${IFRAME_SANDBOX}`; + : `${sandbox_user_params} ${IFRAME_SANDBOX_SAME_ORIGIN}`; return html`