456 lines
19 KiB
TypeScript
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();
|
|
}
|
|
}
|