CI / Build & Quality Checks (push) Successful in 5m0s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 12s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 13m13s
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
48 lines
1.8 KiB
TypeScript
48 lines
1.8 KiB
TypeScript
import { ClientWidgetApi } from 'matrix-widget-api';
|
|
|
|
type MessageLike = Pick<MessageEvent, 'source' | 'origin'>;
|
|
|
|
/**
|
|
* 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, 'addEventListener' | 'removeEventListener'> = 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);
|
|
};
|