feat(widgets): ask before a room widget may read or send in the room (#205)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 9m15s

Room widgets were limited to display-only capabilities because the driver
couldn't serve anything else. Now:

- classifyWidgetCapabilities: display caps are still granted silently; reading
  or sending events/state in the widget's OWN room is offered to the user;
  everything else (other rooms' timelines, to-device, account data, uploads,
  user directory, delayed events) stays denied. Writing protected state
  (power levels, join rules, encryption, membership, ACLs, widgets, …) is
  never offered, and the driver refuses it again at send time.
- WidgetPermissionPrompt names the widget, the host that runs it and who added
  it; each request in plain words ("Send messages of type m.text in this
  room · as you"); reading is pre-ticked, sending is not; Deny / Escape grant
  nothing extra. "Remember my choice" stores it per viewer (localStorage),
  tied to the widget URL, so a changed URL asks again.
- GeneralWidgetDriver implements sendEvent / readRoomTimeline / readRoomState
  / readEventRelations, each refusing any room but the widget's own;
  RoomWidgetView feeds the room's live (decrypted) events and state updates,
  which ClientWidgetApi forwards only if the widget holds the capability.

Verified in Chromium with a cross-origin test widget against a local Synapse:
power-levels and timeline:* requests are never shown; after allowing
send+read the widget's message lands on the server, its power-levels write
is rejected, it receives live messages, and after a reload the remembered
choice skips the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 21:17:18 -04:00
co-authored by Claude Opus 5.5
parent c96c47dd0d
commit c1661e48fa
5 changed files with 618 additions and 21 deletions
@@ -1,10 +1,24 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
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 { isWidgetUrlSafe } from './widgetUtils';
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
type PendingAsk = {
requests: WidgetPermissionRequest[];
origin: string;
resolve: (allowed: Set<Capability>, remember: boolean) => void;
};
type RoomWidgetViewProps = {
room: Room;
@@ -12,7 +26,9 @@ type RoomWidgetViewProps = {
};
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
// that wait on the client handshake load), with a conservative capability driver.
// 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) {
@@ -21,6 +37,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const widgetRef = useRef(widget);
widgetRef.current = widget;
const [blocked, setBlocked] = useState(false);
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
useEffect(() => {
const container = containerRef.current;
@@ -53,10 +70,57 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
iframe.style.border = 'none';
container.append(iframe);
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
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);
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();
};
@@ -73,5 +137,22 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
);
}
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
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}
/>
)}
</>
);
}