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
163 lines
5.8 KiB
TypeScript
163 lines
5.8 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
|
import {
|
|
MatrixEvent,
|
|
MatrixEventEvent,
|
|
Room,
|
|
RoomEvent,
|
|
RoomEventHandlerMap,
|
|
RoomStateEvent,
|
|
} from 'matrix-js-sdk';
|
|
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
|
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 = {
|
|
requests: WidgetPermissionRequest[];
|
|
origin: string;
|
|
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
|
};
|
|
|
|
type RoomWidgetViewProps = {
|
|
room: Room;
|
|
widget: Widget;
|
|
};
|
|
|
|
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
|
// that wait on the client handshake load). Display-only access is granted
|
|
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
|
|
// and, once granted, the room's live events are fed to the widget.
|
|
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
|
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|
const mx = useMatrixClient();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const widgetRef = useRef(widget);
|
|
widgetRef.current = widget;
|
|
const [blocked, setBlocked] = useState(false);
|
|
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
|
|
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return undefined;
|
|
const current = widgetRef.current;
|
|
|
|
const completeUrl = current.getCompleteUrl({
|
|
currentUserId: mx.getSafeUserId(),
|
|
widgetRoomId: room.roomId,
|
|
deviceId: mx.getDeviceId() ?? undefined,
|
|
baseUrl: mx.baseUrl,
|
|
});
|
|
|
|
// Security: never render a same-origin widget with allow-same-origin (a
|
|
// same-origin frame could break out of the sandbox against our own origin).
|
|
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
|
setBlocked(true);
|
|
return undefined;
|
|
}
|
|
setBlocked(false);
|
|
|
|
const iframe = document.createElement('iframe');
|
|
iframe.title = current.name || 'Widget';
|
|
iframe.sandbox.value =
|
|
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
|
iframe.allow = 'autoplay; clipboard-write;';
|
|
iframe.src = completeUrl;
|
|
iframe.style.width = '100%';
|
|
iframe.style.height = '100%';
|
|
iframe.style.border = 'none';
|
|
container.append(iframe);
|
|
|
|
let origin = completeUrl;
|
|
try {
|
|
origin = new URL(completeUrl).host;
|
|
} catch {
|
|
// isWidgetUrlSafe already rejected unparsable URLs
|
|
}
|
|
let cancelAsk: (() => void) | undefined;
|
|
const driver = new GeneralWidgetDriver(
|
|
mx,
|
|
room.roomId,
|
|
{ id: current.id, url: current.templateUrl },
|
|
(requests) =>
|
|
new Promise((resolve) => {
|
|
let settled = false;
|
|
const done = (allowed: Set<Capability>, remember: boolean) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
cancelAsk = undefined;
|
|
setPendingAsk(undefined);
|
|
resolve({ allowed, remember });
|
|
};
|
|
cancelAsk = () => done(new Set(), false);
|
|
setPendingAsk({ requests, origin, resolve: done });
|
|
}),
|
|
);
|
|
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
|
|
// (ClientWidgetApi drops anything the widget has no capability for).
|
|
const feed = (ev: MatrixEvent) => {
|
|
if (ev.getRoomId() !== room.roomId) return;
|
|
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
|
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
};
|
|
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
|
if (toStart || removed || !data?.liveEvent) return;
|
|
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
|
else feed(ev);
|
|
};
|
|
const onState = (ev: MatrixEvent) => {
|
|
if (ev.getRoomId() !== room.roomId) return;
|
|
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
};
|
|
mx.on(RoomEvent.Timeline, onTimeline);
|
|
mx.on(RoomStateEvent.Events, onState);
|
|
|
|
return () => {
|
|
mx.off(RoomEvent.Timeline, onTimeline);
|
|
mx.off(RoomStateEvent.Events, onState);
|
|
cancelAsk?.();
|
|
clientApi.stop();
|
|
iframe.remove();
|
|
};
|
|
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
|
|
|
if (blocked) {
|
|
return (
|
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
|
<Text size="T300" align="Center">
|
|
This widget can't be loaded because its URL is on this app's own origin.
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const addedBy = widget.creatorUserId
|
|
? (room.getMember(widget.creatorUserId)?.rawDisplayName ?? widget.creatorUserId)
|
|
: undefined;
|
|
|
|
return (
|
|
<>
|
|
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
|
{pendingAsk && (
|
|
<WidgetPermissionPrompt
|
|
widgetName={widget.name || 'Widget'}
|
|
origin={pendingAsk.origin}
|
|
addedBy={addedBy}
|
|
requests={pendingAsk.requests}
|
|
onDone={pendingAsk.resolve}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|