From df395776b4b8c3735a4c8a77765b7a0fcac19c94 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 22:22:57 -0400 Subject: [PATCH] fix(security): widget API only accepts messages from the widget's own frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matrix-widget-api's host transport handled a message from ANY window on the page as long as it carried the widget's id; its strictOriginCheck only compares with the host's own origin and is off by default. The call's id is the fixed 'call-embed', so any other frame (a room widget, a URL-preview embed) could post fromWidget actions as the call. Reproduced locally: an opaque-origin frame posting one io.lotus.hotkey keydown for the PTT key turned a push-to-talk user's mic on ("● Live"). restrictWidgetMessages() swaps each ClientWidgetApi transport's listener for one that requires ev.source === the widget iframe's window and ev.origin === the widget's origin. Applied to the call and to room widgets (so one widget can't impersonate another). Verified: the spoof no longer opens the mic; PTT/deafen from inside the call, screenshare, speaking indicator and room widgets (capability prompt, send, live events) unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- .../features/room/widgets/RoomWidgetView.tsx | 4 ++ src/app/plugins/call/CallEmbed.ts | 3 ++ src/app/plugins/widgetTransport.test.ts | 46 ++++++++++++++++++ src/app/plugins/widgetTransport.ts | 47 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/app/plugins/widgetTransport.test.ts create mode 100644 src/app/plugins/widgetTransport.ts diff --git a/src/app/features/room/widgets/RoomWidgetView.tsx b/src/app/features/room/widgets/RoomWidgetView.tsx index 4a0bbf838..47a16508a 100644 --- a/src/app/features/room/widgets/RoomWidgetView.tsx +++ b/src/app/features/room/widgets/RoomWidgetView.tsx @@ -12,6 +12,7 @@ import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-a import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { GeneralWidgetDriver } from './GeneralWidgetDriver'; import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils'; +import { restrictWidgetMessages } from '../../../plugins/widgetTransport'; import { WidgetPermissionPrompt } from './WidgetPermissionPrompt'; type PendingAsk = { @@ -96,6 +97,9 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) { }), ); const clientApi = new ClientWidgetApi(current, iframe, driver); + // Only messages from this widget's own frame and origin are handled, so a + // widget can't impersonate another one (or the call). + restrictWidgetMessages(clientApi, iframe, current.origin); clientApi.setViewedRoomId(room.roomId); // Live room events for widgets that were allowed to see them diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index ba78559bb..6ef04d341 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -29,6 +29,7 @@ import { CallControl } from './CallControl'; import { CallControlState } from './CallControlState'; import { verifyDenoiseAssets } from './denoiseSmokeCheck'; import { canDelegateCapability } from './utils'; +import { restrictWidgetMessages } from '../widgetTransport'; // Maximum time to wait for the embedded Element Call iframe to progress from // initial load to a ready/joined state. If it hasn't by then, we assume the @@ -305,6 +306,8 @@ export class CallEmbed { const callWidgetDriver: WidgetDriver = new CallWidgetDriver(mx, room.roomId); const call: ClientWidgetApi = new ClientWidgetApi(widget, iframe, callWidgetDriver); + // Only messages from the call's own frame and origin reach the widget API. + restrictWidgetMessages(call, iframe, widget.origin); this.mx = mx; this.call = call; diff --git a/src/app/plugins/widgetTransport.test.ts b/src/app/plugins/widgetTransport.test.ts new file mode 100644 index 000000000..8af6e059e --- /dev/null +++ b/src/app/plugins/widgetTransport.test.ts @@ -0,0 +1,46 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { ClientWidgetApi } from 'matrix-widget-api'; +import { isFromWidgetFrame, restrictWidgetMessages } from './widgetTransport'; + +const frame = {} as Window; +const other = {} as Window; +const ORIGIN = 'https://call.example.org'; + +test('isFromWidgetFrame: only the widget frame at its origin', () => { + assert.equal(isFromWidgetFrame({ source: frame, origin: ORIGIN }, frame, ORIGIN), true); + assert.equal(isFromWidgetFrame({ source: other, origin: ORIGIN }, frame, ORIGIN), false); + assert.equal(isFromWidgetFrame({ source: frame, origin: 'null' }, frame, ORIGIN), false); + assert.equal( + isFromWidgetFrame({ source: frame, origin: 'https://evil.example' }, frame, ORIGIN), + false, + ); + assert.equal(isFromWidgetFrame({ source: null, origin: ORIGIN }, null, ORIGIN), false); +}); + +test('restrictWidgetMessages swaps the listener and drops foreign messages', () => { + const seen: unknown[] = []; + const original = (ev: MessageEvent) => { + seen.push(ev.data); + }; + const transport = { handleMessage: original }; + const api = { transport } as unknown as ClientWidgetApi; + const iframe = { contentWindow: frame } as HTMLIFrameElement; + const listeners = new Set([original as EventListener]); + const inbound = { + addEventListener: (_t: string, l: EventListener) => listeners.add(l), + removeEventListener: (_t: string, l: EventListener) => listeners.delete(l), + } as unknown as Window; + + restrictWidgetMessages(api, iframe, ORIGIN, inbound); + assert.equal(listeners.has(original as EventListener), false); + assert.equal(listeners.size, 1); + assert.equal(transport.handleMessage === original, false); + + const dispatch = (ev: Partial) => + listeners.forEach((l) => l(ev as unknown as Event)); + dispatch({ source: other, origin: ORIGIN, data: 'spoof' }); + dispatch({ source: frame, origin: 'null', data: 'wrong-origin' }); + dispatch({ source: frame, origin: ORIGIN, data: 'real' }); + assert.deepEqual(seen, ['real']); +}); diff --git a/src/app/plugins/widgetTransport.ts b/src/app/plugins/widgetTransport.ts new file mode 100644 index 000000000..244e14a18 --- /dev/null +++ b/src/app/plugins/widgetTransport.ts @@ -0,0 +1,47 @@ +import { ClientWidgetApi } from 'matrix-widget-api'; + +type MessageLike = Pick; + +/** + * True when a message came from the widget's own frame, at the origin the + * widget was loaded from. + */ +export const isFromWidgetFrame = ( + ev: MessageLike, + frameWindow: Window | null | undefined, + widgetOrigin: string, +): boolean => !!frameWindow && ev.source === frameWindow && ev.origin === widgetOrigin; + +type InboundTransport = { + handleMessage: (ev: MessageEvent) => void; +}; + +/** + * matrix-widget-api's host transport accepts a message from ANY window on the + * page as long as it carries the widget's id (its `strictOriginCheck` only + * compares against the host's own origin, and is off by default). The call + * widget's id is fixed ('call-embed'), so any other frame (a room widget, a + * URL-preview embed) could post fromWidget actions as the call: e.g. a fake + * push-to-talk keydown turned the user's mic on. + * + * Swap the transport's listener for one that only lets through messages from + * this widget's iframe and origin. `stop()` removes `handleMessage`, which is + * the guarded one after this. Call right after `new ClientWidgetApi(...)`, + * which has already started the transport. + */ +export const restrictWidgetMessages = ( + api: ClientWidgetApi, + iframe: HTMLIFrameElement, + widgetOrigin: string, + inbound: Pick = window, +): void => { + const transport = api.transport as unknown as InboundTransport; + const original = transport.handleMessage; + const guarded = (ev: MessageEvent) => { + if (!isFromWidgetFrame(ev, iframe.contentWindow, widgetOrigin)) return; + original(ev); + }; + inbound.removeEventListener('message', original as EventListener); + transport.handleMessage = guarded; + inbound.addEventListener('message', guarded as EventListener); +};