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
No Branch/Tag Specified
lotus
fix/call-theme-before-ready
fix/dfn-worklet-file
test/lotus-feature-e2e
fix/own-presence-display
fix/sso-uia-desktop
seasonal/lunar
seasonal/newyear
seasonal/christmas
chore/build-hygiene
fix/status-save-speed
chore/typing-hygiene
feat/settings-search
feat/settings-pages
chore/security-deps
feat/settings-quick-wins
feat/data-export
feat/stale-sessions
feat/landscape-call
feat/expected-devices
feat/oauth-detect
chore/ec-0.28
chore/sdk-44
feat/key-upload-recovery
feat/keychain-session
feat/vimeo-events
feat/widget-always-on-screen
feat/call-summary-details
feat/widget-popout
fix/ios-long-press
chore/any-cleanup
chore/soundboard-mic-mix
fix/phone-call-controls
fix/a11y-axe-findings
fix/copy-and-reddit-context
feat/about-build-info
fix/schedule-24h
fix/connecting-strip
fix/ui-polish-batch
fix/outbox-multi-tab
fix/widget-share-hardening
fix/side-panel-width
fix/topic-markdown
fix/acl-ip-literals-default
feat/decorations-host
fix/call-disconnected-bar
fix/linux-update-verify
perf/lazy-settings
fix/dnd-busy-presence
fix/threads-stay-unread
fix/private-note-wording
fix/audio-output-frame-ids
fix/pip-minimise
fix/translate-unavailable-copy
fix/status-expiry-account-data
ci/reseed-bundle-budget
fix/close-no-tray
fix/soundboard-not-permitted
fix/denoise-state-notice
docs/features-catchup
fix/desktop-settings-section
fix/dev-denoise-theme
fix/pwa-manifest
fix/translate-feedback
fix/consent-retry-snooze
fix/strip-metadata-packs
fix/preview-deceptive-links
fix/embed-ogurl-provider
fix/settingtile-nesting
fix/shortcuts-dialog
fix/forward-encryption-warning
fix/compress-alpha-anim
fix/screenshare-double-toast
fix/sw-reply-checks
fix/pip-default-position
fix/inapp-toast-permission
fix/mute-timers-account-data
fix/policy-dedupe
fix/nudge-dismissed-strip
fix/kuma-plain-text
fix/rejoin-dm
fix/search-edit-dupes
fix/room-filter-empty
fix/emoji-board-escape
fix/call-failed-retry
fix/share-links-lotus
fix/poll-vote-local-echo
fix/scheduled-from-server
fix/delayed-restart-dedupe
fix/decline-references-notification
fix/stall-watchdog
fix/filtered-timeline-double-count
fix/call-listener-limit
fix/voice-room-no-room-ping
fix/reactions-collapse-loop
fix/group-call-permission
fix/dialog-enter-closes
fix/delivery-status-live
fix/room-upgrade-lotus-state
fix/quiet-hours-tick
fix/logout-wipe-leftovers
fix/inter-font-name
fix/retention-floor-grace
fix/firefox-persist-hang
fix/phantom-ring-after-reload
fix/local-room-names-rest
fix/missed-call-voice-rooms
fix/animated-bg-compositor
fix/link-safety-bypasses
fix/e2e-register-403
fix/local-room-names-everywhere
fix/collapsible-messages
fix/logout-volume-residue
fix/enter-newline-device-local
fix/dnd-restore-status
fix/presence-sticky
fix/location-confirm
fix/reminders-stick
fix/voice-full-distinct-users
fix/presence-pagehide-status
fix/gif-card-aspect
deps/npm-audit-fixes
security-nudge
linux-package-updates
audit-brace-expansion
server-status-banner
desktop-keychain-mirror
desktop-call-origin
clock-skew-lag
offline-outbox
desktop-lotus-links
poll-ui
calls-open-in-browser
screenshare-prompt
call-origin-split
update-packages
sw-fix
read-me-update
image-path-changes
dm-calls
fix-2469
renovate/element-hq-element-call-embedded-0.x
renovate/npm-i18next-http-backend-vulnerability
renovate/npm-vite-vulnerability
dev
docs-update
more-theme
fix-257
imporve-thread-reply
revert-2402-improve-menu-congestion
mxidColor-toggle
update-sw-main-msg
v4.11.1
v4.10.5
v4.10.4
v4.10.3
v4.10.2
v4.10.1
v4.10.0
v4.9.1
v4.9.0
v4.8.1
v4.8.0
v4.7.1
v4.7.0
v4.6.0
v4.5.1
v4.5.0
v4.4.0
v4.3.2
v4.3.0
v4.2.3
v4.2.2
v4.2.1
v4.2.0
v4.1.0
v4.0.3
v4.0.0
v3.2.0
v3.1.0
v3.0.0
v2.2.6
v2.2.5
v2.2.4
v2.2.3
v2.2.2
v2.2.1
v2.2.0
v2.1.3
v2.1.2
v2.1.1
v2.1.0
v2.0.4
v2.0.3
v2.0.2
v2.0.1
v2.0.0
v1.8.2
v1.8.1
v1.8.0
v1.7.0
v1.6.1
v1.6.0
v1.5.1
v1.5.0
v1.4.0
v1.3.2
v1.3.1
v1.3.0
v1.2.1
v1.2.0
v1.1.0
v1.0.0
Labels
Clear labels
a11y
area: appearance
area: auth-session
area: build-ci
area: calls
area: desktop
area: media
area: messaging
area: mobile
area: moderation
area: navigation
area: notifications
area: settings
area: threads
bug
dependencies
docs
duplicate
enhancement
help wanted
invalid
needs-human-review
performance
planning
priority: critical
priority: high
priority: low
priority: medium
qa
question
research
security
tech-debt
ux
wontfix
Accessibility: keyboard, screen reader, contrast, motion
Client area: appearance
Client area: auth-session
Client area: build-ci
Client area: calls
Client area: desktop
Client area: media
Client area: messaging
Client area: mobile
Client area: moderation
Client area: navigation
Client area: notifications
Client area: settings
Client area: threads
Something is not working
Third-party package versions and advisories
README / LOTUS_* docs wrong or missing
This issue or pull request already exists
New feature
Need some help
Something is wrong
Re-render storms, leaks, heavy work on hot paths
Data loss, security hole, or crash on a main path
Broken feature or serious usability problem
Minor issue or polish
Wrong behaviour in an edge case or notable degradation
Manual QA: shipped, needs a human in a real environment
More information is needed
XSS, unsafe URLs, data leaks, auth/session
Code health, dead code, fragile patterns
Usability or visual inconsistency
This won't be fixed
Milestone
No items
No Milestone
Audit 2026-09 · Medium & Low
Projects
Clear projects
No projects
Notifications
Due Date
Dependencies
No dependencies set.
Reference: LotusGuild/cinny#43
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Severity: medium · Type: security · Confidence: high
Location:
src/app/plugins/call/CallEmbed.ts:225-259, contrastsrc/app/features/room/widgets/widgetUtils.ts:34-41andsrc/app/features/room/widgets/RoomWidgetView.tsx:38-49Problem
the bundled Element Call widget is loaded from
window.location.origin(
/public/element-call/index.html) into an iframe carryingsandbox="… allow-scripts allow-same-origin …". For a same-origin document those two tokens togethercancel the sandbox entirely: the frame can reach
parent.document, register service-worker messages,and read
localStorage— includingcinny_session_v1/cinny_access_token. The widget code pathright next door (
RoomWidgetView) explicitly refuses to render a same-origin widget for exactly thisreason, 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:-lessseparate host) so
allow-same-originmeans the frame's own origin, or dropallow-same-originandgive EC the storage it needs over the widget postMessage transport.
Filed from the September 2026 client audit (branch
lotus@4bea4895).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:CallControl.ts— screenshare, leave, settings, reactions, spotlight/grid (click()on[data-testid=…])io.lotus.*actions in the fork (toggle_screenshare,set_layout,open_settings,open_reactions); leave already hashangupvia the widget APICallControl.ts:335-336(screensharedata-kind, spotlightchecked),useCallSpeakers.ts(tile::beforebackground for speaking)io.lotus.call_statealready carries speaking + participants (the DOM scrape is only the fallback for an old fork); add screenshare/layout to that payloadCallEmbed.tsapplyStyles/setTheme(injects#lotus-ec-styles, hides EC's own controls),CallControl.ts:224-229lotus=1URL param); theme already goes overThemeChangeuseCallHotkeys.tsbindIframeWindowio.lotus.hotkey; or keep the host-side binding for the window only and accept focus-in-frame gapsRecommended order (each step ships on its own and is safe with
allow-same-originstill on):io.lotusactions and the screenshare/layout fields incall_state, and move the injected CSS into the fork.allow-same-origin(or serve EC from its own origin). One catch: under an opaque origin EC loseslocalStorage/IndexedDB, and it useslocalStoragefor 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.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 becauseCallControlcallsscreenshareButton.click()on EC's DOM synchronously inside your click. With same-origin access, that call runs while the gesture is still live. A widget-APIpostMessage(io.lotus.set_screenshare) arrives in the frame without that activation, so EC'sgetDisplayMedia()would throwNotAllowedErrorand 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:matrix-widget-api'sPostmessageTransport) has to be patched, or bypassed for this one message, so it can passdelegate. It doesn't expose postMessage options today.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.
Steps 1–2, first slice: layout, settings and reactions go over the widget API
Fork: element-call
v0.25.0-lotus.14(6beebd3e)io.lotus.set_layout { layout: "grid" | "spotlight" }. It calls the layout switch'ssetLayout.io.lotus.open_settings { open?: boolean }. It callssetSettingsOpen, and toggles whenopenis omitted.io.lotus.toggle_reactions. It fires a window event that the reactions button listens for.io.lotus.controls_state { screensharing, layout }, sent on change. When it arrives, the host knows the fork supports the three actions above.Host (
a0d1c565)controls_statehas arrived, the call bar's Spotlight/Grid View, Reactions and Settings send those actions instead of.click()ing EC's hidden buttons.Verified in a two-person local call (Synapse + LiveKit) with the locally built fork:
controls_statearrives on join.Still on the DOM, and why:
getDisplayMedianeeds the click to reach the frame. This is the Capability Delegation step.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.Those three, plus the origin split, are the remaining work on this issue.
Screenshare over the widget API (Capability Delegation): done
Fork
v0.25.0-lotus.17(6fa34911)io.lotus.set_screenshare { on?: boolean }(omitonto toggle). It calls the view model'stoggleScreenSharingsynchronously, so a delegated activation is still live when it runs.controls_statenow also carriesscreenshareAction: true, so the host only uses the action on a fork that has it.Host (
024edbf5, pins lotus.17)postMessage(msg, { targetOrigin, delegate: "display-capture" })inside the user's click.matrix-widget-apihas no postMessage options, so itssendInternalis swapped out for that one synchronous send.*target, so the frame's real origin is used.allow-same-originis still on.Verified in a local call (Synapse + LiveKit):
set_screensharereached EC;screensharing: true; EC buttonprimaryfalse;secondaryuserAgentDataremoved (non-Chromium path)Correction to my earlier comment
I wrote that Chromium's
getDisplayMedianeeds transient user activation in the calling frame. In this Chromium (153, headless, with the fake capture UI), aset_screenshareposted 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: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
postMessageis accepted (Chrome throws on a missing activation or a*target), the message arrives, and the share works.What this means for dropping
allow-same-originNon-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.
Styles and hotkeys moved to the fork, plus a live hotkey bug fixed
Fork
v0.25.0-lotus.18(7855a0c2) andv0.25.0-lotus.19(e0eecc52), host502b9dfd(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:
html, body { background: none }lotusTransparent:root { color-scheme: <theme> }darkwhile the host is light, and the browser paints an opaque backdropposition:absolute; visibility:hidden)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._avatarContainer_1mrho_40 ._avatar_va14e_8centringVerified: no
#lotus-ec-styleselement and no inline styles on EC's footer. The footer computes tohidden/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
io.lotus.set_hotkeys { codes }(the PTT and deafen keys).io.lotus.hotkey: keydown/keyup plus modifiers, whether the target is a text field or a button, and window focus changes.controls_statecarrieshotkeys: 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:
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:
data-kindreads, the speaking scrape inuseCallSpeakers). The bundled fork is always new enough, so these can simply be deleted.allow-same-originis dropped, those need EC's own screenshare button shown instead.call.chat.lotusguild.orgor droppingallow-same-origin. EC needslocalStorage, so a separate origin is likely the way.Non-Chromium screenshare fallback, and the host no longer reads the call frame
Fork
v0.25.0-lotus.20(0f93368a) · host49dec686(pins lotus.20)Screenshare where the click can't be delegated
Firefox, Safari and the Linux desktop app (WebKitGTK) have no Capability Delegation, and
getDisplayMedianeeds the user's click inside the frame. Until now the host clicked EC's hidden button through the DOM, which stops working onceallow-same-origingoes.lotusFrameScreenshareto 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_statenow reportsframeScreenshare: true. The room call bar and the status bar then hide their own screenshare button. The screenshare-audio mute stays.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.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):
useCallSpeakers;io.lotus.call_stateis now the only sourcegetCallDocumentThe host's only remaining handle on the frame is
contentWindow.postMessage, which works cross-origin.Verified (local Synapse + LiveKit, published lotus.20)
userAgentDataremoved)lotusFrameScreensharepresentscreensharingtrue → false{visible:false}, then{visible:true}; frame button 0, then 1Regression runs after the removal:
call_statealone.What's left
Only the origin split: serve Element Call from its own origin (e.g.
call.chat.lotusguild.org) and dropallow-same-originfrom 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.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 fakeio.lotus.hotkeykeydown and switched a push-to-talk user's mic on.Every
ClientWidgetApi(the call and room widgets) now only handles messages whosesourceis its own iframe and whoseoriginis the widget's. Verified:Origin split: three PRs, nothing merged or deployed.
blob:URL is origin-bound)elementCallUrlinconfig.json, inert until setcall.chat.lotusguild.orgnginx block (serves only/public/element-call/),frame-ancestors, and the Permissions-Policy delegationEach 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).
Rolled out: the call page now runs on
call.chat.lotusguild.org(web)v0.25.0-lotus.21nginx -t. The app now delegates mic/camera/screen/autoplay to the call origin.nginx.confnginx -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.10.10.10.6:80with a new Let's Encrypt cert 108 (Cloudflare DNS;*.lotusguild.orgdoesn'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.jsonelementCallUrlset in place on the live file (the other keys, includinggifApiKey, are kept; backup in/root/config-backups/) and committed (9baafd4).Verified live, without logging in: the real app page on
chat.lotusguild.orgembedding the realcall.chatpage, with the same sandbox and allow attributes:getUserMediais allowed, with microphone/camera/display-capture delegatedparent.documentandparent.localStorage→SecurityErrormatrix.lotusguild.orgis blocked.The LiveKit token service answers the new origin (
/sfu/getand/get_tokenpreflight 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
elementCallUrlfrom/var/www/html/config.jsonon 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.
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.chatandchatserve the same bundle.Tests:
VIDEO/SCREEN_SHAREon LiveKit, bar Stop.Production: the app boots with no errors, and the live
call.chatpage 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.Desktop: call page on its own origin (in review)
http://127.0.0.1:<port>when its config setsdesktopCallOrigin(same port, release builds only).desktopCallOrigin, allows it in the CSPframe-src, and gives that origin mic/camera/screen only.parent.localStoragethrows in the call frame), then merge. This issue can be closed after that.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
windowsrunner (run 2292), passed 9/9:127.0.0.1(was::1only before #27)http://127.0.0.1:44548http://localhost:9333/)With the web app on
call.chat.lotusguild.organd the desktop app on its own loopback origin, the call frame is isolated on both platforms. Closing.