vojo/src/app/features/bots/BotWidgetEmbed.ts

456 lines
19 KiB
TypeScript

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 `<a target="_blank">` 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 = '<attacker>'`
// 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<string>();
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 `<a target="_blank">` 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<IRoomEvent>;
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<void> {
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();
}
}