import { MatrixClient, MatrixEvent, MatrixEventEvent, EventType, Room, RoomEvent, RoomStateEvent, type RoomEventHandlerMap, } from 'matrix-js-sdk'; import { ClientWidgetApi, type IRoomEvent, MatrixWidgetType, Widget, WidgetApiToWidgetAction, type WidgetDriver, } from 'matrix-widget-api'; import { Theme } from '../../hooks/useTheme'; import { openExternalUrl } from '../../utils/capacitor'; import type { BotPreset } from './catalog'; import { BotWidgetDriver, sanitizeBotWidgetMessageEvent, sanitizeBotWidgetRedactionEvent, } from './BotWidgetDriver'; export type BotWidgetEmbedOptions = { mx: MatrixClient; room: Room; preset: BotPreset; container: HTMLElement; theme: Theme; language: string; onError: (error: Error) => void; onReady?: () => void; }; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; const getBotWidgetUrl = ( preset: BotPreset, room: Room, mx: MatrixClient, theme: Theme, language: string, widgetId: string ): string => { if (!preset.experience) throw new Error('Bot widget experience is not configured'); const url = new URL(preset.experience.url, window.location.origin); url.searchParams.set('widgetId', widgetId); url.searchParams.set('parentUrl', window.location.origin); url.searchParams.set('roomId', room.roomId); url.searchParams.set('userId', mx.getSafeUserId()); url.searchParams.set('botId', preset.id); url.searchParams.set('botMxid', preset.mxid); url.searchParams.set('commandPrefix', preset.experience.commandPrefix); url.searchParams.set('theme', theme.kind); url.searchParams.set('clientLanguage', language); url.searchParams.set('baseUrl', mx.baseUrl); const deviceId = mx.getDeviceId(); if (deviceId) url.searchParams.set('deviceId', deviceId); return url.toString(); }; const createBotWidget = ( preset: BotPreset, room: Room, mx: MatrixClient, theme: Theme, language: string ): Widget => { const widgetId = getBotWidgetId(preset); return new Widget({ id: widgetId, creatorUserId: mx.getSafeUserId(), type: MatrixWidgetType.Custom, name: preset.name, url: getBotWidgetUrl(preset, room, mx, theme, language, widgetId), waitForIframeLoad: true, data: { title: `${preset.name} Bot`, }, }); }; // Build the iframe with all attributes EXCEPT `src`. The caller must assign // `iframe.src` only AFTER `new ClientWidgetApi(widget, iframe, driver)` has // attached its internal `load` listener — same-origin static widget HTML can // finish loading before the listener is wired, and `beginCapabilities()` then // never fires. This is the canonical Element-Web ordering. const createBotIframe = (preset: BotPreset): HTMLIFrameElement => { const iframe = document.createElement('iframe'); iframe.title = `${preset.name} Bot`; // Sandbox baseline: scripts + forms + same-origin (per docs/plans/ // bots_tab.md M8). Plus `allow-popups` + `allow-popups-to-escape- // sandbox` so widget-side `` actually opens — // every shipped widget surfaces external links (mautrix bridge // GitHub repos in About modals, Meta ToS in the WhatsApp warning // modal) and without popups those clicks are silently dropped. // `allow-popups-to-escape-sandbox` lets the opened tab run without // inheriting our sandbox, which is what users expect when leaving // the iframe for an external site. // // `allow-downloads` stays off — no current widget needs to deliver // downloadable content. Add it as a per-preset opt-in if a future // widget genuinely needs it. // // Threat-model honesty: the sandbox here is STRUCTURAL, not // adversarial. // (a) The widget is served cross-origin (widgets.vojo.chat in prod, // localhost:8081 in dev) so the documented `allow-scripts` + // `allow-same-origin` same-origin-escape doesn't apply — same- // origin refers to the iframe's OWN origin, not the host's. The // widget can't read host (vojo.chat) localStorage / cookies // because it's a different origin entirely. // (b) The actual security boundary against a compromised widget // bundle is BotWidgetDriver — capability allowlist, sanitizer // (only `m.text` / `m.notice` / `m.image` fields the bridge // needs, no mxc / file / info), strict 1:1 room invariant in // `isSafeBotWidgetRoom`. A hostile bundle would still see only // the events the driver hands it. // (c) Popups require user interaction (a click) to open under modern // browser pop-up blockers, so a bundle can't spam-open windows // in the background. // If we ever serve the widget same-origin (e.g. inlined as a static // bundle under /widgets/ on vojo.chat), drop `allow-same-origin` here // — the postMessage transport doesn't need it, and the same-origin // sandbox-escape becomes real once the iframe shares the host's // origin. iframe.setAttribute( 'sandbox', 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox' ); iframe.allow = 'clipboard-write'; iframe.referrerPolicy = 'no-referrer'; iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = 'none'; return iframe; }; export class BotWidgetEmbed { public readonly api: ClientWidgetApi; public readonly iframe: HTMLIFrameElement; private disposed = false; // Gate live event/state feeding on the widget completing its capability // handshake. Without this, an initial-sync burst from matrix-js-sdk can // call api.feedEvent / api.feedStateUpdate while the postMessage transport // has no widget on the other end yet — pending sends can timeout/reject and // demote the widget into the chat fallback even though the widget is fine. private isReady = false; // Two halves of «widget is fully booted». `iframeLoaded` flips on the // iframe's `load` event (document complete + same-origin assets ready); // `handshakeReady` flips on the ClientWidgetApi `ready` event (host // capability handshake complete). The external `onReady` callback only // fires once BOTH have happened — defensive coupling so a same-origin // synchronous handshake (cached bundle) can't beat the document-complete // signal and trigger a premature loading-bar fade. In practice load // tends to fire first, but the order is not guaranteed by the spec. private iframeLoaded = false; private handshakeReady = false; private readonly disposables: Array<() => void> = []; // Expected origin of the widget iframe — captured once at construction // from `iframe.src`. Used by `onWidgetMessage` to validate inbound // postMessages: a source-window check alone is NOT sufficient because // a compromised widget bundle could `window.location.href = ''` // and the browser keeps the same WindowProxy across same-frame // navigation, so `iframe.contentWindow` would still match. Pinning // `ev.origin` to the original widget origin closes that gap (see // PortSwigger: «Controlling the web message source»). private widgetOrigin = ''; // Dedup events that have already been forwarded to the widget. Encrypted DMs // hit feedEvent twice in succession (once from RoomEvent.Timeline with the // ciphertext, once from MatrixEventEvent.Decrypted with the plaintext); pure // chat rooms can also re-emit Timeline on store-rehydrate. Track by event_id // so the widget never receives the same message twice. private readonly fedEventIds = new Set(); private readonly onTimelineEvent: RoomEventHandlerMap[RoomEvent.Timeline] = ( ev, timelineRoom, toStartOfTimeline, _removed, data ) => { if (timelineRoom?.roomId !== this.room.roomId) return; // Back-paginated history must not replay into a fresh widget session — // the widget asks for history explicitly via readRoomTimeline. if (toStartOfTimeline) return; if (data?.liveEvent === false) return; this.mx.decryptEventIfNeeded(ev); this.feedEvent(ev); }; private readonly onEventDecrypted = (ev: MatrixEvent) => { if (ev.getRoomId() === this.room.roomId) this.feedEvent(ev); }; private readonly onStateUpdate = (ev: MatrixEvent) => { if (ev.getRoomId() !== this.room.roomId) return; this.feedStateUpdate(ev); }; // Side-channel postMessage handler for the widget's `openExternalUrl` // call. Distinct from matrix-widget-api's `fromWidget` channel // (`api: io.vojo.bot-widget` instead of `api: fromWidget`) so it // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // of our extension and avoids the «unknown action» reply path. // // Why this exists: the host's global `setupExternalLinkHandler` // (utils/capacitor.ts) intercepts `` clicks at // the host document level and routes them via Capacitor's Browser // plugin. But cross-origin iframes don't bubble click events into // the parent document, so widget-side links are invisible to it — // on Capacitor's Android WebView those clicks silently disappear. // The widget posts this message; we validate the URL and forward // to the same `openExternalUrl` helper the host uses elsewhere. // // Security gates (defence in depth): // 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this // check, a compromised widget bundle could `window.location.href // = 'https://attacker.example/'` — the browser keeps the same // WindowProxy across same-frame navigation, so `iframe.contentWindow` // stays equal even after the iframe is hijacked. With it, only // messages from the original widget origin are honoured. See // PortSwigger «Controlling the web message source»: // https://portswigger.net/web-security/dom-based/controlling-the-web-message-source // 2. Source must also be our iframe's contentWindow (an unrelated // iframe of the SAME origin — e.g. an ad embed loaded into a // sibling frame on the same origin in a future deployment — // could otherwise pass the origin check). // 3. Only https URLs are honoured. We tightened from http+https to // https-only because no shipped widget content links over plain // http; rejecting http closes a cleartext-redirect vector via // Capacitor `Browser.open` on Android. // 4. javascript:, data:, file:, etc. are implicitly rejected by (3). private readonly onWidgetMessage = (ev: MessageEvent) => { if (ev.origin !== this.widgetOrigin) return; if (ev.source !== this.iframe.contentWindow) return; const msg = ev.data as | { api?: unknown; action?: unknown; data?: { url?: unknown } } | undefined; if (!msg || typeof msg !== 'object') return; if (msg.api !== 'io.vojo.bot-widget') return; if (msg.action !== 'open-external-url') return; const url = msg.data?.url; if (typeof url !== 'string') return; try { const parsed = new URL(url); if (parsed.protocol !== 'https:') return; } catch { return; } void openExternalUrl(url); }; public constructor(private readonly options: BotWidgetEmbedOptions) { const { mx, room, preset, container, theme, language } = options; const widget = createBotWidget(preset, room, mx, theme, language); const widgetUrl = widget.getCompleteUrl({ currentUserId: mx.getSafeUserId(), clientTheme: theme.kind, clientLanguage: language, widgetRoomId: room.roomId, }); // Pin the expected origin BEFORE the iframe loads — `onWidgetMessage` // will reject anything whose `ev.origin` doesn't match. `new URL` // resolves a relative `widgetUrl` (e.g. dev `/widgets/...`) against // the host's origin, so this works whether the widget is served // cross-origin (prod widgets.vojo.chat) or same-origin (dev local // bundle / future inlined deployment). this.widgetOrigin = new URL(widgetUrl, window.location.origin).origin; // Strict ordering — DO NOT reorder: // 1. Build iframe with NO src. // 2. Append iframe to DOM (creates contentWindow). // 3. Construct ClientWidgetApi (attaches the internal `load` listener // that triggers beginCapabilities()). // 4. Only now assign iframe.src. // If we set src in step 1 or 2, a same-origin static page can finish // loading before step 3 wires the listener — beginCapabilities() never // fires, no Capabilities request is sent to-widget, no `ready` event, // and the widget hangs forever on a blank handshake. const iframe = createBotIframe(preset); container.append(iframe); // External onReady fires only when BOTH the iframe document is fully // loaded AND the ClientWidgetApi handshake is complete — see // `iframeLoaded` / `handshakeReady` field comments. The host gate this // serves is the loading-bar overlay in BotWidgetMount. const fireExternalReady = () => { if (this.iframeLoaded && this.handshakeReady) { this.options.onReady?.(); } }; // Attach the iframe load listener BEFORE the src assignment for the // same reason ClientWidgetApi attaches its internal listener early — // a cached same-origin document can finish loading before src returns // control here, and a late listener would miss the load event. const onIframeLoad = () => { this.iframeLoaded = true; fireExternalReady(); }; iframe.addEventListener('load', onIframeLoad); const driver: WidgetDriver = new BotWidgetDriver(mx, room.roomId, preset); const api = new ClientWidgetApi(widget, iframe, driver); this.api = api; this.iframe = iframe; api.setViewedRoomId(room.roomId); iframe.src = widgetUrl; const onReady = () => { this.isReady = true; this.handshakeReady = true; // eslint-disable-next-line no-console console.info('[BotWidget] handshake complete'); fireExternalReady(); }; // Element-Web treats `error:preparing` as informational — it never tears // down the widget. Vojo follows the same rule: log the upstream cause and // leave the iframe mounted. The user can fall back to the chat manually // via the «Show chat» toolbar; transient handshake hiccups (slow widget // boot, dev hot-reload, mid-launch theme race) no longer demote a working // session into chat. Real teardown still happens for: iframe network // failure, room becomes unsafe, and host unmount. const onPreparingError = (error: unknown) => { // eslint-disable-next-line no-console console.warn('[BotWidget] error:preparing (left mounted)', error); }; const onIframeError = (event: Event) => { // eslint-disable-next-line no-console console.error('[BotWidget] iframe error', event); this.fail(new Error('Bot widget iframe failed to load')); }; api.on('ready', onReady); api.on('error:preparing', onPreparingError); iframe.addEventListener('error', onIframeError); room.on(RoomEvent.Timeline, this.onTimelineEvent); mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); mx.on(RoomStateEvent.Events, this.onStateUpdate); window.addEventListener('message', this.onWidgetMessage); this.disposables.push( () => iframe.removeEventListener('load', onIframeLoad), () => api.off('ready', onReady), () => api.off('error:preparing', onPreparingError), () => iframe.removeEventListener('error', onIframeError), () => room.removeListener(RoomEvent.Timeline, this.onTimelineEvent), () => mx.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted), () => mx.removeListener(RoomStateEvent.Events, this.onStateUpdate), () => window.removeEventListener('message', this.onWidgetMessage) ); } private get mx(): MatrixClient { return this.options.mx; } private get room(): Room { return this.options.room; } private fail(error: Error): void { if (this.disposed) return; // eslint-disable-next-line no-console console.error('[BotWidget] fail:', error.message, error); this.options.onError(error); this.destroy(); } // No per-event isSafeBotWidgetRoom re-check on the feed paths. Initial sync // can transiently report "unsafe" before all members hydrate, and the per- // event fail() that used to live here would tear the widget down on flap. // Membership-driven teardown is the responsibility of `useBotRoom` upstream: // when the room transitions out of `ready`, BotExperienceHost dispatches to // a different state branch and unmounts the widget through React. The // driver still applies the safety gate on every capability/sendEvent/read* // call (BotWidgetDriver.getSafeRoom) so a hostile widget can't escalate. private feedEvent(ev: MatrixEvent): void { if (this.disposed || !this.isReady) return; if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; const raw = ev.getEffectiveEvent() as Partial; if (raw.room_id !== this.room.roomId) return; if (typeof raw.event_id !== 'string' || raw.event_id.length === 0) return; if (this.fedEventIds.has(raw.event_id)) return; // Dispatch by event type — m.room.message and m.room.redaction take // different sanitizer paths (the QR-login flow needs both: edits arrive // as m.image with `m.relates_to`, the post-scan cleanup arrives as a // separate m.room.redaction targeting the QR event id). const sanitized = raw.type === EventType.RoomRedaction ? sanitizeBotWidgetRedactionEvent(raw as IRoomEvent) : sanitizeBotWidgetMessageEvent(raw as IRoomEvent); if (!sanitized) return; this.fedEventIds.add(raw.event_id); this.api.feedEvent(sanitized).catch((error) => { // eslint-disable-next-line no-console console.warn('[BotWidget] feedEvent rejected', error); }); } private feedStateUpdate(ev: MatrixEvent): void { if (this.disposed || !this.isReady) return; if (ev.getType() !== EventType.RoomMember) return; const stateKey = ev.getStateKey(); if (stateKey !== this.mx.getSafeUserId() && stateKey !== this.options.preset.mxid) return; this.api.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch((error) => { // eslint-disable-next-line no-console console.warn('[BotWidget] feedStateUpdate rejected', error); }); } // Push a runtime theme change. No-op until the widget completes the // capability handshake — the initial theme is already in the URL params, so // sending it pre-handshake would just queue a postMessage request that times // out after 10 s in the transport (matrix-widget-api default). public setTheme(theme: Theme): Promise { if (this.disposed || !this.isReady) return Promise.resolve(); return this.api.transport .send(WidgetApiToWidgetAction.ThemeChange, { name: theme.kind }) .then(() => undefined); } public destroy(): void { if (this.disposed) return; this.disposed = true; this.disposables.forEach((dispose) => dispose()); this.api.stop(); this.iframe.remove(); this.fedEventIds.clear(); } }