43f4ceb45d
Phase C.1 of the protocol-gaps roadmap, gate-green (693 tests). Generalizes the Element Call widget host into a general room-widget feature: - StateEvent.Widget + widgetsPanelAtom + useRoomWidgets (WidgetParser). - RoomWidgetView: sandboxed-iframe host via ClientWidgetApi with a conservative GeneralWidgetDriver (approves only benign display caps — no room-event send/read/to-device). Blocks same-origin widget URLs (sandbox breakout guard). - WidgetsPanel: list / open / add / remove, PL-gated on im.vector.modular.widgets, https + non-same-origin URL validation. Mounted like the media gallery (header toggle + 3-way content-panel exclusivity + mobile full-screen overlay). - Tested URL/capability/id helpers. Requires the prod CSP frame-src widening (matrix repo) for external widgets. v1 cuts (capability consent prompt, Jitsi/sticker types, user widgets) noted. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
46 lines
1.8 KiB
TypeScript
46 lines
1.8 KiB
TypeScript
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
|
|
|
|
// Conservative v1 capability policy: approve only benign display capabilities.
|
|
// Everything else (room-event send/receive, to-device, uploads, user-directory,
|
|
// delayed events, TURN servers) is denied — a random widget must not be able to
|
|
// act as the user or read room data without an explicit consent flow (follow-up).
|
|
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
|
|
MatrixCapabilities.AlwaysOnScreen,
|
|
MatrixCapabilities.RequiresClient,
|
|
MatrixCapabilities.Screenshots,
|
|
]);
|
|
|
|
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
|
|
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
|
|
|
|
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
|
|
|
|
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
|
|
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
|
|
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return 'empty';
|
|
let url: URL;
|
|
try {
|
|
url = new URL(trimmed);
|
|
} catch {
|
|
return 'invalid';
|
|
}
|
|
if (url.protocol !== 'https:') return 'not-https';
|
|
if (url.origin === appOrigin) return 'same-origin';
|
|
return undefined;
|
|
};
|
|
|
|
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
|
|
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
|
|
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
|
|
try {
|
|
return new URL(completeUrl).origin !== appOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
export const generateWidgetId = (): string =>
|
|
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|