Files
cinny/src/app/plugins/widgetTransport.test.ts
T
Lotus CIandClaude Opus 5.5 df395776b4
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
fix(security): widget API only accepts messages from the widget's own frame
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
2026-09-26 22:22:57 -04:00

47 lines
2.0 KiB
TypeScript

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<EventListener>([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<MessageEvent>) =>
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']);
});