Element Call iframe is same-origin, so its sandbox is a no-op #43

Closed
opened 2026-09-12 01:51:10 -04:00 by jared · 11 comments
Owner

Severity: medium · Type: security · Confidence: high

Location: src/app/plugins/call/CallEmbed.ts:225-259, contrast src/app/features/room/widgets/widgetUtils.ts:34-41 and src/app/features/room/widgets/RoomWidgetView.tsx:38-49

Problem

the bundled Element Call widget is loaded from window.location.origin
(/public/element-call/index.html) into an iframe carrying
sandbox="… allow-scripts allow-same-origin …". For a same-origin document those two tokens together
cancel the sandbox entirely: the frame can reach parent.document, register service-worker messages,
and read localStorage — including cinny_session_v1 / cinny_access_token. The widget code path
right next door (RoomWidgetView) explicitly refuses to render a same-origin widget for exactly this
reason, so the call embed is the one place the stated policy is inverted. EC is first-party bundled
code, so this is defence-in-depth rather than an active hole — but EC renders remote-controlled call
and member data, and any DOM-injection bug there becomes full session compromise.

How to trigger

join a call; in devtools evaluate document.querySelector('iframe[title="Call Embed"]').contentWindow.localStorage.getItem('cinny_session_v1') — it returns the access token.

Suggested fix

serve the EC bundle from a distinct origin (a subdomain or a blob:/data:-less
separate host) so allow-same-origin means the frame's own origin, or drop allow-same-origin and
give EC the storage it needs over the widget postMessage transport.


Filed from the September 2026 client audit (branch lotus @ 4bea4895).

**Severity:** medium · **Type:** security · **Confidence:** high **Location:** `src/app/plugins/call/CallEmbed.ts:225-259`, contrast `src/app/features/room/widgets/widgetUtils.ts:34-41` and `src/app/features/room/widgets/RoomWidgetView.tsx:38-49` ### Problem the bundled Element Call widget is loaded from `window.location.origin` (`/public/element-call/index.html`) into an iframe carrying `sandbox="… allow-scripts allow-same-origin …"`. For a same-origin document those two tokens together cancel the sandbox entirely: the frame can reach `parent.document`, register service-worker messages, and read `localStorage` — including `cinny_session_v1` / `cinny_access_token`. The widget code path right next door (`RoomWidgetView`) explicitly *refuses* to render a same-origin widget for exactly this reason, so the call embed is the one place the stated policy is inverted. EC is first-party bundled code, so this is defence-in-depth rather than an active hole — but EC renders remote-controlled call and member data, and any DOM-injection bug there becomes full session compromise. ### How to trigger join a call; in devtools evaluate `document.querySelector('iframe[title="Call Embed"]').contentWindow.localStorage.getItem('cinny_session_v1')` — it returns the access token. ### Suggested fix serve the EC bundle from a distinct origin (a subdomain or a `blob:`/`data:`-less separate host) so `allow-same-origin` means the frame's *own* origin, or drop `allow-same-origin` and give EC the storage it needs over the widget postMessage transport. --- _Filed from the September 2026 client audit (branch `lotus` @ 4bea4895)._
jared added this to the Audit 2026-09 · Medium & Low milestone 2026-09-12 01:51:10 -04:00
jared added the bugpriority: mediumsecurityarea: calls labels 2026-09-12 01:51:10 -04:00
jared self-assigned this 2026-09-12 01:51:10 -04:00
Author
Owner

Audit: what breaks if the call frame loses same-origin access

I checked the "drop allow-same-origin" option before trying it. It can't be a one-line change: the host reaches into Element Call's DOM in 24 places across 5 files, and all of them would stop working once the frame has an opaque (or different) origin:

Group Where What it does today Replacement
Clicking EC's buttons CallControl.ts — screenshare, leave, settings, reactions, spotlight/grid (click() on [data-testid=…]) Lotus's own call bar drives EC by clicking its hidden controls New io.lotus.* actions in the fork (toggle_screenshare, set_layout, open_settings, open_reactions); leave already has hangup via the widget API
Reading EC's state from the DOM CallControl.ts:335-336 (screenshare data-kind, spotlight checked), useCallSpeakers.ts (tile ::before background for speaking) Mirrors screenshare/layout/speaking into the host UI Mostly covered: io.lotus.call_state already carries speaking + participants (the DOM scrape is only the fallback for an old fork); add screenshare/layout to that payload
Style injection CallEmbed.ts applyStyles / setTheme (injects #lotus-ec-styles, hides EC's own controls), CallControl.ts:224-229 Hides EC's control bar, transparent background, theme fixes Move the CSS into the fork (Lotus-only stylesheet, keyed off a lotus=1 URL param); theme already goes over ThemeChange
Keyboard hooks on the frame's window useCallHotkeys.ts bindIframeWindow PTT / deafen keys keep working while focus is inside the call frame Fork listens for the keys itself and forwards via io.lotus.hotkey; or keep the host-side binding for the window only and accept focus-in-frame gaps

Recommended order (each step ships on its own and is safe with allow-same-origin still on):

  1. Fork: add the missing io.lotus actions and the screenshare/layout fields in call_state, and move the injected CSS into the fork.
  2. Host: switch each touchpoint to the action; keep the DOM path only as a fallback for an older fork.
  3. Remove the fallbacks, then drop allow-same-origin (or serve EC from its own origin). One catch: under an opaque origin EC loses localStorage/IndexedDB, and it uses localStorage for its own settings and device state. So either give it a separate origin (e.g. call.chat.lotusguild.org, plus a second localhost port on desktop), or confirm EC runs with storage denied.

The final origin decision needs you (DNS, certificate and proxy, plus the desktop localhost plugin). Steps 1-2 don't, and they also remove the brittle [data-testid] scraping that breaks whenever EC renames something. I'll take steps 1-2 as a follow-up unless you'd rather keep it parked.

## Audit: what breaks if the call frame loses same-origin access I checked the "drop `allow-same-origin`" option before trying it. It can't be a one-line change: the host reaches into Element Call's DOM in **24 places across 5 files**, and all of them would stop working once the frame has an opaque (or different) origin: | Group | Where | What it does today | Replacement | |---|---|---|---| | **Clicking EC's buttons** | `CallControl.ts` — screenshare, leave, settings, reactions, spotlight/grid (`click()` on `[data-testid=…]`) | Lotus's own call bar drives EC by clicking its hidden controls | New `io.lotus.*` actions in the fork (`toggle_screenshare`, `set_layout`, `open_settings`, `open_reactions`); leave already has `hangup` via the widget API | | **Reading EC's state from the DOM** | `CallControl.ts:335-336` (screenshare `data-kind`, spotlight `checked`), `useCallSpeakers.ts` (tile `::before` background for speaking) | Mirrors screenshare/layout/speaking into the host UI | Mostly covered: `io.lotus.call_state` already carries speaking + participants (the DOM scrape is only the fallback for an old fork); add screenshare/layout to that payload | | **Style injection** | `CallEmbed.ts` `applyStyles` / `setTheme` (injects `#lotus-ec-styles`, hides EC's own controls), `CallControl.ts:224-229` | Hides EC's control bar, transparent background, theme fixes | Move the CSS into the fork (Lotus-only stylesheet, keyed off a `lotus=1` URL param); theme already goes over `ThemeChange` | | **Keyboard hooks on the frame's window** | `useCallHotkeys.ts` `bindIframeWindow` | PTT / deafen keys keep working while focus is inside the call frame | Fork listens for the keys itself and forwards via `io.lotus.hotkey`; or keep the host-side binding for the window only and accept focus-in-frame gaps | **Recommended order** (each step ships on its own and is safe with `allow-same-origin` still on): 1. Fork: add the missing `io.lotus` actions and the screenshare/layout fields in `call_state`, and move the injected CSS into the fork. 2. Host: switch each touchpoint to the action; keep the DOM path only as a fallback for an older fork. 3. Remove the fallbacks, then drop `allow-same-origin` (or serve EC from its own origin). One catch: under an opaque origin EC loses `localStorage`/IndexedDB, and it uses `localStorage` for its own settings and device state. So either give it a separate origin (e.g. `call.chat.lotusguild.org`, plus a second localhost port on desktop), or confirm EC runs with storage denied. The final origin decision needs you (DNS, certificate and proxy, plus the desktop localhost plugin). Steps 1-2 don't, and they also remove the brittle `[data-testid]` scraping that breaks whenever EC renames something. I'll take steps 1-2 as a follow-up unless you'd rather keep it parked.
Author
Owner

Correction to step 1: screenshare needs the user's click to cross the frame boundary

getDisplayMedia() requires transient user activation in the calling frame. Today the host's screenshare button works because CallControl calls screenshareButton.click() on EC's DOM synchronously inside your click. With same-origin access, that call runs while the gesture is still live. A widget-API postMessage (io.lotus.set_screenshare) arrives in the frame without that activation, so EC's getDisplayMedia() would throw NotAllowedError and the Lotus call-bar screenshare button would break.

The way through is Capability Delegation: iframe.contentWindow.postMessage(msg, { targetOrigin, delegate: 'display-capture' }), sent inside the click handler. That means:

  • The widget transport (matrix-widget-api's PostmessageTransport) has to be patched, or bypassed for this one message, so it can pass delegate. It doesn't expose postMessage options today.
  • Support: Chromium only (desktop app/WebView2 included). Firefox and Safari don't support it, so on those browsers the screenshare control would have to live inside EC's own UI (a visible EC button) rather than the host bar.
  • The other actions (layout, settings, reactions, styles, and state read-back) don't need activation, so steps 1-2 still work for them.

Revised plan: do everything except screenshare over the widget API first. Screenshare goes last, via delegation, with an in-frame fallback on non-Chromium. The origin split (step 3) should wait until that fallback is decided.

### Correction to step 1: screenshare needs the user's click to cross the frame boundary `getDisplayMedia()` requires **transient user activation in the calling frame**. Today the host's screenshare button works because `CallControl` calls `screenshareButton.click()` on EC's DOM **synchronously inside your click**. With same-origin access, that call runs while the gesture is still live. A widget-API `postMessage` (`io.lotus.set_screenshare`) arrives in the frame *without* that activation, so EC's `getDisplayMedia()` would throw `NotAllowedError` and the Lotus call-bar screenshare button would break. The way through is **Capability Delegation**: `iframe.contentWindow.postMessage(msg, { targetOrigin, delegate: 'display-capture' })`, sent inside the click handler. That means: - The widget transport (`matrix-widget-api`'s `PostmessageTransport`) has to be patched, or bypassed for this one message, so it can pass `delegate`. It doesn't expose postMessage options today. - Support: Chromium only (desktop app/WebView2 included). **Firefox and Safari don't support it**, so on those browsers the screenshare control would have to live inside EC's own UI (a visible EC button) rather than the host bar. - The other actions (layout, settings, reactions, styles, and state read-back) don't need activation, so steps 1-2 still work for them. Revised plan: do everything except screenshare over the widget API first. Screenshare goes last, via delegation, with an in-frame fallback on non-Chromium. The origin split (step 3) should wait until that fallback is decided.
Author
Owner

Steps 1–2, first slice: layout, settings and reactions go over the widget API

Fork: element-call v0.25.0-lotus.14 (6beebd3e)

  • toWidget io.lotus.set_layout { layout: "grid" | "spotlight" }. It calls the layout switch's setLayout.
  • toWidget io.lotus.open_settings { open?: boolean }. It calls setSettingsOpen, and toggles when open is omitted.
  • toWidget io.lotus.toggle_reactions. It fires a window event that the reactions button listens for.
  • fromWidget io.lotus.controls_state { screensharing, layout }, sent on change. When it arrives, the host knows the fork supports the three actions above.

Host (a0d1c565)

  • Once controls_state has arrived, the call bar's Spotlight/Grid View, Reactions and Settings send those actions instead of .click()ing EC's hidden buttons.
  • The call bar's screenshare and layout state now come from the report, not from the MutationObserver on EC's DOM.
  • Older forks never send the report, so they keep the DOM path.

Verified in a two-person local call (Synapse + LiveKit) with the locally built fork:

  • controls_state arrives on join.
  • Spotlight View and Grid View flip EC's layout, and the reported layout follows.
  • Reactions opens EC's reactions menu.
  • Settings opens EC's settings modal.
  • All four go over postMessage; none are DOM clicks.
  • Starting and stopping a screenshare flips the host's button through the report.

Still on the DOM, and why:

  • Screenshare start/stop. getDisplayMedia needs the click to reach the frame. This is the Capability Delegation step.
  • The injected CSS that hides EC's control bar. EC already has showControls=false, but it removes the bar from the DOM. That would take the screenshare button and the reactions menu's anchor with it. So the CSS can only go after screenshare moves.
  • Hotkeys bound on the frame's window.

Those three, plus the origin split, are the remaining work on this issue.

## Steps 1–2, first slice: layout, settings and reactions go over the widget API **Fork: element-call `v0.25.0-lotus.14` (`6beebd3e`)** - toWidget `io.lotus.set_layout { layout: "grid" | "spotlight" }`. It calls the layout switch's `setLayout`. - toWidget `io.lotus.open_settings { open?: boolean }`. It calls `setSettingsOpen`, and toggles when `open` is omitted. - toWidget `io.lotus.toggle_reactions`. It fires a window event that the reactions button listens for. - fromWidget `io.lotus.controls_state { screensharing, layout }`, sent on change. When it arrives, the host knows the fork supports the three actions above. **Host (`a0d1c565`)** - Once `controls_state` has arrived, the call bar's Spotlight/Grid View, Reactions and Settings send those actions instead of `.click()`ing EC's hidden buttons. - The call bar's screenshare and layout state now come from the report, not from the MutationObserver on EC's DOM. - Older forks never send the report, so they keep the DOM path. **Verified** in a two-person local call (Synapse + LiveKit) with the locally built fork: - `controls_state` arrives on join. - Spotlight View and Grid View flip EC's layout, and the reported layout follows. - Reactions opens EC's reactions menu. - Settings opens EC's settings modal. - All four go over postMessage; none are DOM clicks. - Starting and stopping a screenshare flips the host's button through the report. **Still on the DOM, and why:** - **Screenshare start/stop.** `getDisplayMedia` needs the click to reach the frame. This is the Capability Delegation step. - **The injected CSS that hides EC's control bar.** EC already has `showControls=false`, but it removes the bar from the DOM. That would take the screenshare button and the reactions menu's anchor with it. So the CSS can only go after screenshare moves. - **Hotkeys bound on the frame's window.** Those three, plus the origin split, are the remaining work on this issue.
Author
Owner

Screenshare over the widget API (Capability Delegation): done

Fork v0.25.0-lotus.17 (6fa34911)

  • New toWidget io.lotus.set_screenshare { on?: boolean } (omit on to toggle). It calls the view model's toggleScreenSharing synchronously, so a delegated activation is still live when it runs.
  • controls_state now also carries screenshareAction: true, so the host only uses the action on a fork that has it.

Host (024edbf5, pins lotus.17)

  • On Chromium (including the WebView2 desktop app), the call bar's Start/Stop Screenshare sends the action with postMessage(msg, { targetOrigin, delegate: "display-capture" }) inside the user's click.
    • matrix-widget-api has no postMessage options, so its sendInternal is swapped out for that one synchronous send.
    • Delegation refuses a * target, so the frame's real origin is used.
  • On Firefox, Safari and WebKitGTK (the Linux desktop app) there is no delegation, so they still click EC's button. That still works because allow-same-origin is still on.

Verified in a local call (Synapse + LiveKit):

start stop
Chromium 1 set_screenshare reached EC; screensharing: true; EC button primary 2nd message; false; secondary
userAgentData removed (non-Chromium path) 0 messages; the DOM click shared 0 messages; stopped

Correction to my earlier comment

I wrote that Chromium's getDisplayMedia needs transient user activation in the calling frame. In this Chromium (153, headless, with the fake capture UI), a set_screenshare posted by a script, with no click and no delegation, also started a share. So here, at least, the plain widget message would have worked on Chromium. I couldn't check whether real, non-headless Chrome enforces the click, so I don't know. My understanding, which I didn't verify here, is:

  • Firefox and Safari do enforce it and don't support delegation.
  • Chromium may enforce it in the future, which is what delegation is for.

The design above is right in every case: delegation where it exists, and the DOM click elsewhere. What I couldn't observe is delegation actually unlocking a capture, because this build doesn't enforce the click. What I did confirm: the delegated postMessage is accepted (Chrome throws on a missing activation or a * target), the message arrives, and the share works.

What this means for dropping allow-same-origin

Non-Chromium engines can only start a share from inside the frame. Once same-origin goes, the host bar's button on Firefox, Safari and Linux desktop needs a fallback: show EC's own screenshare button there (EC's UI, where the click is native). That's the one real decision left before step 3.

Remaining: move the injected CSS into the fork; hotkeys inside the frame; the origin split plus that non-Chromium fallback.

## Screenshare over the widget API (Capability Delegation): done **Fork `v0.25.0-lotus.17`** (`6fa34911`) - New toWidget `io.lotus.set_screenshare { on?: boolean }` (omit `on` to toggle). It calls the view model's `toggleScreenSharing` synchronously, so a delegated activation is still live when it runs. - `controls_state` now also carries `screenshareAction: true`, so the host only uses the action on a fork that has it. **Host** (`024edbf5`, pins lotus.17) - On **Chromium** (including the WebView2 desktop app), the call bar's Start/Stop Screenshare sends the action with `postMessage(msg, { targetOrigin, delegate: "display-capture" })` inside the user's click. - `matrix-widget-api` has no postMessage options, so its `sendInternal` is swapped out for that one synchronous send. - Delegation refuses a `*` target, so the frame's real origin is used. - On **Firefox, Safari and WebKitGTK** (the Linux desktop app) there is no delegation, so they still click EC's button. That still works because `allow-same-origin` is still on. **Verified** in a local call (Synapse + LiveKit): | | start | stop | |---|---|---| | Chromium | 1 `set_screenshare` reached EC; `screensharing: true`; EC button `primary` | 2nd message; `false`; `secondary` | | `userAgentData` removed (non-Chromium path) | 0 messages; the DOM click shared | 0 messages; stopped | ### Correction to my earlier comment I wrote that Chromium's `getDisplayMedia` needs transient user activation in the calling frame. In this Chromium (153, headless, with the fake capture UI), a `set_screenshare` posted by a script, with no click and no delegation, **also** started a share. So here, at least, the plain widget message would have worked on Chromium. I couldn't check whether real, non-headless Chrome enforces the click, so I don't know. My understanding, which I didn't verify here, is: - Firefox and Safari do enforce it and don't support delegation. - Chromium may enforce it in the future, which is what delegation is for. The design above is right in every case: delegation where it exists, and the DOM click elsewhere. What I **couldn't** observe is delegation actually unlocking a capture, because this build doesn't enforce the click. What I did confirm: the delegated `postMessage` is accepted (Chrome throws on a missing activation or a `*` target), the message arrives, and the share works. ### What this means for dropping `allow-same-origin` Non-Chromium engines can only start a share from inside the frame. Once same-origin goes, the host bar's button on Firefox, Safari and Linux desktop needs a fallback: show EC's own screenshare button there (EC's UI, where the click is native). That's the one real decision left before step 3. **Remaining:** move the injected CSS into the fork; hotkeys inside the frame; the origin split plus that non-Chromium fallback.
Author
Owner

Styles and hotkeys moved to the fork, plus a live hotkey bug fixed

Fork v0.25.0-lotus.18 (7855a0c2) and v0.25.0-lotus.19 (e0eecc52), host 502b9dfd (pins lotus.19)

1. The host no longer writes CSS into the call frame

First I checked what each injected rule actually does in a live EC 0.25 call:

injected by the host in EC 0.25 now
html, body { background: none } already done by the fork's lotusTransparent dropped
:root { color-scheme: <theme> } needed: without it the frame's root is dark while the host is light, and the browser paints an opaque backdrop fork sets it from the theme (live switch verified: light → dark)
hide EC's footer (inline position:absolute; visibility:hidden) needed fork: lotusHostControls=1 → body.lotus-host-controls. It stays in the DOM, because the non-Chromium screenshare path still clicks its button
[style*="height: 0"]… spacer hide matches nothing dropped
._avatarContainer_1mrho_40 ._avatar_va14e_8 centring matches nothing (the hashes changed) dropped

Verified: no #lotus-ec-styles element and no inline styles on EC's footer. The footer computes to hidden / absolute, the root scheme follows the theme, and the non-Chromium screenshare fallback still works.

Also fixed (existing bug): in the light theme, tile name tags were near-black pills with dark text, so unreadable. The fork's Lotus canvas override (#0c0d10) applied in every theme. It now applies to dark themes only.

2. The host no longer adds key listeners to the frame's window

  • The host sends io.lotus.set_hotkeys { codes } (the PTT and deafen keys).
  • The fork reports those keys as io.lotus.hotkey: keydown/keyup plus modifiers, whether the target is a text field or a button, and window focus changes.
  • It cancels the default action inside the frame with the host's rules.
  • controls_state carries hotkeys: true. The window binding is kept only for an older fork.

Live bug found and fixed. EC has built-in shortcuts: M toggles the mic and Space is its own PTT. With focus inside the call (for example after clicking a tile) and the default deafen key M, both ran. Reproduced on the current production code (lotus.17 plus the live host), in PTT mode:

first M second M
before mic turned ON, not deafened deafened
after (lotus.19) deafened, mic untouched undeafened

A push-to-talk user's mic opened without holding the key. EC's shortcuts now stand down for keys the host owns; its other shortcuts (V, H, 1–9, and M/Space when not claimed) are unchanged.

Also verified: holding Space inside the frame opens PTT (the chip shows "● Live") and releasing closes it, all over the new messages (focus, keydown:Space, keyup:Space). Unit tests cover both sides; the fork runs 731 tests, the host 1249 plus e2e.

What's left on this issue

All of the host's writes into the frame are gone. It still reads EC's DOM in a few places, and still clicks EC's screenshare button on non-Chromium engines:

  • The DOM-state fallbacks for forks older than lotus.14 (layout, reactions and settings clicks, data-kind reads, the speaking scrape in useCallSpeakers). The bundled fork is always new enough, so these can simply be deleted.
  • The decision: on Firefox, Safari and the Linux desktop app, screenshare from the host bar needs the click inside the frame. Once allow-same-origin is dropped, those need EC's own screenshare button shown instead.
  • Then the origin split: either call.chat.lotusguild.org or dropping allow-same-origin. EC needs localStorage, so a separate origin is likely the way.
## Styles and hotkeys moved to the fork, plus a live hotkey bug fixed **Fork `v0.25.0-lotus.18`** (`7855a0c2`) and **`v0.25.0-lotus.19`** (`e0eecc52`), **host** `502b9dfd` (pins lotus.19) ### 1. The host no longer writes CSS into the call frame First I checked what each injected rule actually does in a live EC 0.25 call: | injected by the host | in EC 0.25 | now | |---|---|---| | `html, body { background: none }` | already done by the fork's `lotusTransparent` | dropped | | `:root { color-scheme: <theme> }` | **needed**: without it the frame's root is `dark` while the host is light, and the browser paints an opaque backdrop | fork sets it from the theme (live switch verified: light → dark) | | hide EC's footer (inline `position:absolute; visibility:hidden`) | needed | fork: `lotusHostControls=1` → `body.lotus-host-controls`. It stays in the DOM, because the non-Chromium screenshare path still clicks its button | | `[style*="height: 0"]…` spacer hide | **matches nothing** | dropped | | `._avatarContainer_1mrho_40 ._avatar_va14e_8` centring | **matches nothing** (the hashes changed) | dropped | Verified: no `#lotus-ec-styles` element and no inline styles on EC's footer. The footer computes to `hidden` / `absolute`, the root scheme follows the theme, and the non-Chromium screenshare fallback still works. **Also fixed (existing bug):** in the light theme, tile name tags were near-black pills with dark text, so unreadable. The fork's Lotus canvas override (`#0c0d10`) applied in every theme. It now applies to dark themes only. ### 2. The host no longer adds key listeners to the frame's window - The host sends `io.lotus.set_hotkeys { codes }` (the PTT and deafen keys). - The fork reports those keys as `io.lotus.hotkey`: keydown/keyup plus modifiers, whether the target is a text field or a button, and window focus changes. - It cancels the default action inside the frame with the host's rules. - `controls_state` carries `hotkeys: true`. The window binding is kept only for an older fork. **Live bug found and fixed.** EC has built-in shortcuts: **M toggles the mic** and **Space is its own PTT**. With focus inside the call (for example after clicking a tile) and the default deafen key M, both ran. Reproduced on the **current production code** (lotus.17 plus the live host), in PTT mode: | | first M | second M | |---|---|---| | before | **mic turned ON**, not deafened | deafened | | after (lotus.19) | deafened, mic untouched | undeafened | A push-to-talk user's mic opened without holding the key. EC's shortcuts now stand down for keys the host owns; its other shortcuts (V, H, 1–9, and M/Space when not claimed) are unchanged. Also verified: holding Space inside the frame opens PTT (the chip shows "● Live") and releasing closes it, all over the new messages (`focus, keydown:Space, keyup:Space`). Unit tests cover both sides; the fork runs 731 tests, the host 1249 plus e2e. ### What's left on this issue All of the host's *writes* into the frame are gone. It still *reads* EC's DOM in a few places, and still clicks EC's screenshare button on non-Chromium engines: - The DOM-state fallbacks for forks older than lotus.14 (layout, reactions and settings clicks, `data-kind` reads, the speaking scrape in `useCallSpeakers`). The bundled fork is always new enough, so these can simply be deleted. - **The decision:** on Firefox, Safari and the Linux desktop app, screenshare from the host bar needs the click inside the frame. Once `allow-same-origin` is dropped, those need EC's own screenshare button shown instead. - Then the origin split: either `call.chat.lotusguild.org` or dropping `allow-same-origin`. EC needs `localStorage`, so a separate origin is likely the way.
Author
Owner

Non-Chromium screenshare fallback, and the host no longer reads the call frame

Fork v0.25.0-lotus.20 (0f93368a) · host 49dec686 (pins lotus.20)

Screenshare where the click can't be delegated

Firefox, Safari and the Linux desktop app (WebKitGTK) have no Capability Delegation, and getDisplayMedia needs the user's click inside the frame. Until now the host clicked EC's hidden button through the DOM, which stops working once allow-same-origin goes.

  • On those engines the host adds lotusFrameScreenshare to the call URL. The fork then shows EC's own screenshare button floating in the frame's bottom-right corner (the rest of EC's footer stays hidden), so the click is native.
  • controls_state now reports frameScreenshare: true. The room call bar and the status bar then hide their own screenshare button. The screenshare-audio mute stays.
  • Room policy: new toWidget io.lotus.set_frame_screenshare { visible }. Where the room forbids screensharing (io.lotus.room_quality.allow_screenshare: false), the frame button hides just like ours, but never while a share is live, so it can still be stopped.
  • Chromium is unchanged: the host bar's button sends the delegated set_screenshare.

Old-fork fallbacks removed

The bundled fork is always ≥ lotus.14, so everything that read or clicked EC's DOM is gone (−499 lines):

  • the button lookups (screenshare, layout, settings, reactions, leave) and their MutationObservers
  • the frame-window hotkey binding
  • the speaking/muted tile scrape in useCallSpeakers; io.lotus.call_state is now the only source
  • getCallDocument

The host's only remaining handle on the frame is contentWindow.postMessage, which works cross-origin.

Verified (local Synapse + LiveKit, published lotus.20)

Chromium no delegation (userAgentData removed)
URL flag absent lotusFrameScreenshare present
host screenshare buttons 2 (call bar + status bar) 0; screenshare-audio button still 1
frame button none 1
start / stop host button: screensharing true → false frame button: true → false
policy off / restored n/a host sent {visible:false}, then {visible:true}; frame button 0, then 1

Regression runs after the removal:

  • Layout, reactions, settings: Spotlight/Grid view, the reactions menu and the settings modal all work over the widget API.
  • Hotkeys: holding Space inside the frame opens PTT, and M deafens without touching the mic.
  • Speaking indicator: the status bar shows "bob is speaking" from call_state alone.
  • Tests: fork unit tests 109 + 6 new component tests; host 1,261.

frame screenshare button

What's left

Only the origin split: serve Element Call from its own origin (e.g. call.chat.lotusguild.org) and drop allow-same-origin from the sandbox. The host is ready for it now. That's a server/DNS/TLS change on your side (nginx vhost, cert, EC config), so it's your call.

## Non-Chromium screenshare fallback, and the host no longer reads the call frame **Fork `v0.25.0-lotus.20`** (`0f93368a`) · **host** `49dec686` (pins lotus.20) ### Screenshare where the click can't be delegated Firefox, Safari and the Linux desktop app (WebKitGTK) have no Capability Delegation, and `getDisplayMedia` needs the user's click inside the frame. Until now the host clicked EC's hidden button through the DOM, which stops working once `allow-same-origin` goes. - On those engines the host adds `lotusFrameScreenshare` to the call URL. The fork then shows **EC's own screenshare button** floating in the frame's bottom-right corner (the rest of EC's footer stays hidden), so the click is native. - `controls_state` now reports `frameScreenshare: true`. The room call bar and the status bar then hide their own screenshare button. The **screenshare-audio mute stays**. - **Room policy:** new toWidget `io.lotus.set_frame_screenshare { visible }`. Where the room forbids screensharing (`io.lotus.room_quality.allow_screenshare: false`), the frame button hides just like ours, but never while a share is live, so it can still be stopped. - Chromium is unchanged: the host bar's button sends the delegated `set_screenshare`. ### Old-fork fallbacks removed The bundled fork is always ≥ lotus.14, so everything that read or clicked EC's DOM is gone (−499 lines): - the button lookups (screenshare, layout, settings, reactions, leave) and their MutationObservers - the frame-window hotkey binding - the speaking/muted tile scrape in `useCallSpeakers`; `io.lotus.call_state` is now the only source - `getCallDocument` The host's only remaining handle on the frame is `contentWindow.postMessage`, which works cross-origin. ### Verified (local Synapse + LiveKit, published lotus.20) | | Chromium | no delegation (`userAgentData` removed) | |---|---|---| | URL flag | absent | `lotusFrameScreenshare` present | | host screenshare buttons | 2 (call bar + status bar) | **0**; screenshare-audio button still 1 | | frame button | none | 1 | | start / stop | host button: `screensharing` true → false | frame button: true → false | | policy off / restored | n/a | host sent `{visible:false}`, then `{visible:true}`; frame button 0, then 1 | Regression runs after the removal: - **Layout, reactions, settings:** Spotlight/Grid view, the reactions menu and the settings modal all work over the widget API. - **Hotkeys:** holding Space inside the frame opens PTT, and M deafens without touching the mic. - **Speaking indicator:** the status bar shows "bob is speaking" from `call_state` alone. - **Tests:** fork unit tests 109 + 6 new component tests; host 1,261. ![frame screenshare button](https://code.lotusguild.org/attachments/39aef1b6-eecf-4238-9998-5856e6c9053e) ### What's left Only the **origin split**: serve Element Call from its own origin (e.g. `call.chat.lotusguild.org`) and drop `allow-same-origin` from the sandbox. The host is ready for it now. That's a server/DNS/TLS change on your side (nginx vhost, cert, EC config), so it's your call.
Author
Owner

Security fix shipped; origin split is up for review (not deployed)

Shipped: df395776, a widget message guard. While checking the split I found that cinny's widget API accepted messages from any frame on the page that used the call's fixed widget id (call-embed). I reproduced the impact locally: an opaque-origin frame posted one fake io.lotus.hotkey keydown and switched a push-to-talk user's mic on.

Every ClientWidgetApi (the call and room widgets) now only handles messages whose source is its own iframe and whose origin is the widget's. Verified:

  • the spoof is blocked
  • PTT/deafen, screenshare, the speaking indicator and room widgets are unchanged
  • CI is green.

Origin split: three PRs, nothing merged or deployed.

  • element-call #41: host-origin message check (the current check would drop every host message cross-origin) and soundboard clip bytes (a host blob: URL is origin-bound)
  • cinny #243: elementCallUrl in config.json, inert until set
  • matrix #12: the call.chat.lotusguild.org nginx block (serves only /public/element-call/), frame-ancestors, and the Permissions-Policy delegation

Each PR has the rollout order, rollback, test evidence and a review checklist. The order matters: with the current fork, a cross-origin call looks joined but host controls do nothing (tested).

## Security fix shipped; origin split is up for review (not deployed) **Shipped: `df395776`, a widget message guard.** While checking the split I found that cinny's widget API accepted messages from *any* frame on the page that used the call's fixed widget id (`call-embed`). I reproduced the impact locally: an opaque-origin frame posted one fake `io.lotus.hotkey` keydown and switched a push-to-talk user's mic **on**. Every `ClientWidgetApi` (the call and room widgets) now only handles messages whose `source` is its own iframe and whose `origin` is the widget's. Verified: - the spoof is blocked - PTT/deafen, screenshare, the speaking indicator and room widgets are unchanged - CI is green. **Origin split: three PRs, nothing merged or deployed.** - element-call **#41**: host-origin message check (the current check would drop every host message cross-origin) and soundboard clip bytes (a host `blob:` URL is origin-bound) - cinny **#243**: `elementCallUrl` in `config.json`, inert until set - matrix **#12**: the `call.chat.lotusguild.org` nginx block (serves only `/public/element-call/`), `frame-ancestors`, and the Permissions-Policy delegation Each PR has the rollout order, rollback, test evidence and a review checklist. The order matters: with the current fork, a cross-origin call looks joined but host controls do nothing (tested).
Author
Owner

Rolled out: the call page now runs on call.chat.lotusguild.org (web)

Step Result
element-call #41 → v0.25.0-lotus.21 Published. The bundle has the host-origin message check and accepts clip bytes for the soundboard.
cinny #243 (pins lotus.21) Live. Before merging, the full call suite ran green against the published package (same-origin): join, both screenshare paths, PTT/deafen, layout/reactions/settings, speaking and mic level, soundboard, avatars, muted-speech warning, volume, spoof blocked.
LXC 106 header snippets Installed behind nginx -t. The app now delegates mic/camera/screen/autoplay to the call origin.
matrix #12 nginx.conf Installed behind nginx -t (the auto-deploy there is broken, matrix #13). Verified: the call host serves only /public/element-call/, and /, /config.json, /sw.js, the webhook and source maps return 404.
NPM Proxy host 57 → 10.10.10.6:80 with a new Let's Encrypt cert 108 (Cloudflare DNS; *.lotusguild.org doesn't cover a second-level name). Force SSL, HSTS, HTTP/2, block exploits, no custom locations. Other hosts are fine, including host 49's LiveKit paths.
config.json elementCallUrl set in place on the live file (the other keys, including gifApiKey, are kept; backup in /root/config-backups/) and committed (9baafd4).

Verified live, without logging in: the real app page on chat.lotusguild.org embedding the real call.chat page, with the same sandbox and allow attributes:

  • the page loads
  • getUserMedia is allowed, with microphone/camera/display-capture delegated
  • parent.document and parent.localStorage → SecurityError
  • no service worker controls the frame
  • framing from matrix.lotusguild.org is blocked.

The LiveKit token service answers the new origin (/sfu/get and /get_token preflight 200, Access-Control-Allow-Origin: *).

Not verified by me: a real call on production. I have no production test account. A test call in Chrome and Firefox (mic, camera, screenshare, soundboard) is the last check.

Rollback: remove elementCallUrl from /var/www/html/config.json on LXC 106 (or restore /root/config-backups/config.json.bak-20260927115228). New calls go back to the bundled same-origin page with no rebuild.

The desktop app is unchanged (bundled, same-origin). That's a separate decision, so I'm leaving this issue open for it and for your call test.

## Rolled out: the call page now runs on `call.chat.lotusguild.org` (web) | Step | Result | |---|---| | element-call #41 → **`v0.25.0-lotus.21`** | Published. The bundle has the host-origin message check and accepts clip bytes for the soundboard. | | cinny #243 (pins lotus.21) | Live. Before merging, the full call suite ran green against the published package (same-origin): join, both screenshare paths, PTT/deafen, layout/reactions/settings, speaking and mic level, soundboard, avatars, muted-speech warning, volume, spoof blocked. | | LXC 106 header snippets | Installed behind `nginx -t`. The app now delegates mic/camera/screen/autoplay to the call origin. | | matrix #12 `nginx.conf` | Installed behind `nginx -t` (the auto-deploy there is broken, matrix #13). Verified: the call host serves only `/public/element-call/`, and `/`, `/config.json`, `/sw.js`, the webhook and source maps return 404. | | NPM | Proxy host **57** → `10.10.10.6:80` with a new Let's Encrypt cert **108** (Cloudflare DNS; `*.lotusguild.org` doesn't cover a second-level name). Force SSL, HSTS, HTTP/2, block exploits, no custom locations. Other hosts are fine, including host 49's LiveKit paths. | | `config.json` | `elementCallUrl` set in place on the live file (the other keys, including `gifApiKey`, are kept; backup in `/root/config-backups/`) and committed (`9baafd4`). | **Verified live, without logging in:** the real app page on `chat.lotusguild.org` embedding the real `call.chat` page, with the same sandbox and allow attributes: - the page loads - `getUserMedia` is allowed, with microphone/camera/display-capture delegated - `parent.document` and `parent.localStorage` → `SecurityError` - no service worker controls the frame - framing from `matrix.lotusguild.org` is blocked. The LiveKit token service answers the new origin (`/sfu/get` and `/get_token` preflight 200, `Access-Control-Allow-Origin: *`). **Not verified by me:** a real call on production. I have no production test account. A test call in Chrome and Firefox (mic, camera, screenshare, soundboard) is the last check. **Rollback:** remove `elementCallUrl` from `/var/www/html/config.json` on LXC 106 (or restore `/root/config-backups/config.json.bak-20260927115228`). New calls go back to the bundled same-origin page with no rebuild. The desktop app is unchanged (bundled, same-origin). That's a separate decision, so I'm leaving this issue open for it and for your call test.
Author
Owner

Screenshare back on the call bar in Firefox/Safari: shipped

element-call #42 → v0.25.0-lotus.22, then cinny #244 (pins lotus.22, e2b23397). Both are live; call.chat and chat serve the same bundle.

  • Firefox, Safari and the Linux desktop app: the screenshare button is on the call bar and status bar again. Clicking it shows "Share your screen?" inside the call, just above the bar. Share starts the share with the frame's own click; Cancel or Escape closes it. The corner button is gone.
  • Picture-in-picture: a compact card inside the small call window. The "Return to call" overlay lets clicks through while the card is open.
  • Room policy: the button hides, and the card refuses to open where screensharing is forbidden.
  • Chrome, Edge and the Windows app: unchanged.

Tests:

  • Chromium without Capability Delegation (the Firefox path), against the published lotus.22, cross- and same-origin: bar button, card, Cancel, Share → VIDEO/SCREEN_SHARE on LiveKit, bar Stop.
  • Chromium unchanged. PiP and policy checks pass.
  • Regression suite green cross-origin; CI green.

Production: the app boots with no errors, and the live call.chat page still embeds with mic/camera/screen delegated and isolated from the app.

Not automated: real Firefox. Headless Firefox stalls before the client starts in this test environment, so the owner is testing it in Firefox.

Rollback: pin 0.25.0-lotus.21 → corner button again.

## Screenshare back on the call bar in Firefox/Safari: shipped element-call #42 → **`v0.25.0-lotus.22`**, then cinny #244 (pins lotus.22, `e2b23397`). Both are live; `call.chat` and `chat` serve the same bundle. - **Firefox, Safari and the Linux desktop app:** the screenshare button is on the call bar and status bar again. Clicking it shows **"Share your screen?"** inside the call, just above the bar. Share starts the share with the frame's own click; Cancel or Escape closes it. The corner button is gone. - **Picture-in-picture:** a compact card inside the small call window. The "Return to call" overlay lets clicks through while the card is open. - **Room policy:** the button hides, and the card refuses to open where screensharing is forbidden. - **Chrome, Edge and the Windows app:** unchanged. **Tests:** - Chromium without Capability Delegation (the Firefox path), against the **published** lotus.22, cross- and same-origin: bar button, card, Cancel, Share → `VIDEO/SCREEN_SHARE` on LiveKit, bar Stop. - Chromium unchanged. PiP and policy checks pass. - Regression suite green cross-origin; CI green. **Production:** the app boots with no errors, and the live `call.chat` page still embeds with mic/camera/screen delegated and isolated from the app. **Not automated:** real Firefox. Headless Firefox stalls before the client starts in this test environment, so the owner is testing it in Firefox. **Rollback:** pin `0.25.0-lotus.21` → corner button again.
Author
Owner

Desktop: call page on its own origin (in review)

  • cinny #252: merged, live, and inert until the desktop opts in. The desktop app loads the bundled call page from http://127.0.0.1:<port> when its config sets desktopCallOrigin (same port, release builds only).
  • cinny-desktop #27: open, stacked on #26. It binds the local server to 127.0.0.1, sets desktopCallOrigin, allows it in the CSP frame-src, and gives that origin mic/camera/screen only.
  • Verified:
    • Linux desktop binary: the frame from 127.0.0.1 gets SecurityError on the app's storage and page, while a same-origin frame reads them.
    • Simulated desktop (Chromium): the full call flow passes 12/12, the same as the same-origin control.
  • Remaining: a Windows test of #26 and #27 (a real call; parent.localStorage throws in the call frame), then merge. This issue can be closed after that.
## Desktop: call page on its own origin (in review) - **cinny #252: merged, live, and inert** until the desktop opts in. The desktop app loads the bundled call page from `http://127.0.0.1:<port>` when its config sets `desktopCallOrigin` (same port, release builds only). - **cinny-desktop #27: open, stacked on #26.** It binds the local server to 127.0.0.1, sets `desktopCallOrigin`, allows it in the CSP `frame-src`, and gives that origin mic/camera/screen only. - **Verified:** - **Linux desktop binary:** the frame from 127.0.0.1 gets **SecurityError** on the app's storage and page, while a same-origin frame reads them. - **Simulated desktop (Chromium):** the full call flow passes 12/12, the same as the same-origin control. - **Remaining:** a Windows test of #26 and #27 (a real call; `parent.localStorage` throws in the call frame), then merge. This issue can be closed after that.
Author
Owner

Desktop half done. cinny #252 and cinny-desktop #27 are merged. The bundled call page now loads from http://127.0.0.1:44548, a separate origin from the app (http://localhost:44548). So, like the web app since #243, the call frame can no longer read the app's storage or page.

Windows smoke test on the published nightly 4.12.371: the real installer, installed and driven on the windows runner (run 2292), passed 9/9:

check result
boots to the login screen ✅
local server listening ✅ 127.0.0.1 (was ::1 only before #27)
microphone for the app ✅
call page from its own origin ✅ http://127.0.0.1:44548
call page reading the app's storage ✅ SecurityError
call page gets the microphone ✅
microphone for a foreign page ✅ NotAllowedError (http://localhost:9333/)
Credential Manager round trip ✅ supported, round trip, restored

With the web app on call.chat.lotusguild.org and the desktop app on its own loopback origin, the call frame is isolated on both platforms. Closing.

**Desktop half done.** cinny #252 and cinny-desktop #27 are merged. The bundled call page now loads from `http://127.0.0.1:44548`, a separate origin from the app (`http://localhost:44548`). So, like the web app since #243, the call frame can no longer read the app's storage or page. **Windows smoke test on the published nightly 4.12.371**: the real installer, installed and driven on the `windows` runner (run 2292), passed **9/9**: | check | result | |---|---| | boots to the login screen | ✅ | | local server listening | ✅ `127.0.0.1` (was `::1` only before #27) | | microphone for the app | ✅ | | call page from its own origin | ✅ `http://127.0.0.1:44548` | | call page reading the app's storage | ✅ **SecurityError** | | call page gets the microphone | ✅ | | microphone for a foreign page | ✅ **NotAllowedError** (`http://localhost:9333/`) | | Credential Manager round trip | ✅ supported, round trip, restored | With the web app on `call.chat.lotusguild.org` and the desktop app on its own loopback origin, the call frame is isolated on both platforms. Closing.
jared closed this issue 2026-09-30 21:07:46 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
2026-11-14
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#43