diff --git a/src/app/features/room/widgets/GeneralWidgetDriver.ts b/src/app/features/room/widgets/GeneralWidgetDriver.ts index 89fb74705..fa2c1570a 100644 --- a/src/app/features/room/widgets/GeneralWidgetDriver.ts +++ b/src/app/features/room/widgets/GeneralWidgetDriver.ts @@ -1,15 +1,190 @@ -import { type Capability, WidgetDriver } from 'matrix-widget-api'; -import { filterWidgetCapabilities } from './widgetUtils'; +import { + type Capability, + type IReadEventRelationsResult, + type IRoomEvent, + type ISendEventDetails, + WidgetDriver, +} from 'matrix-widget-api'; +import { + Direction, + EventType, + type IContent, + type MatrixClient, + type MatrixEvent, + type StateEvents, + type TimelineEvents, +} from 'matrix-js-sdk'; +import { + PROTECTED_STATE_TYPES, + type WidgetPermissionRequest, + classifyWidgetCapabilities, + loadWidgetConsent, + saveWidgetConsent, +} from './widgetUtils'; -// A minimal, conservative WidgetDriver for general room widgets. It only narrows -// the capabilities a widget may hold (to a benign display-only subset — see -// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice -// / uploads …) are inherited from the base WidgetDriver and are never reached, -// because the capabilities that would gate them are denied here. A richer, -// consent-prompt-driven driver is a follow-up. +/** Shows the consent prompt; resolves with the capabilities the user allowed. */ +export type AskWidgetPermissions = ( + requests: WidgetPermissionRequest[], +) => Promise<{ allowed: Set; remember: boolean }>; + +type WidgetIdentity = { id: string; url: string }; + +/** + * WidgetDriver for general room widgets. Display-only capabilities are granted + * silently. Reading or sending events/state in the widget's own room needs the + * user's OK through `ask` (remembered per viewer when they choose). Everything + * else is denied — see classifyWidgetCapabilities. The data methods below are + * only reached for capabilities the user granted (ClientWidgetApi checks), and + * additionally refuse any room other than the widget's own. + */ export class GeneralWidgetDriver extends WidgetDriver { - // eslint-disable-next-line class-methods-use-this + public constructor( + private readonly mx: MatrixClient, + private readonly roomId: string, + private readonly widget: WidgetIdentity, + private readonly ask: AskWidgetPermissions, + ) { + super(); + } + public async validateCapabilities(requested: Set): Promise> { - return filterWidgetCapabilities(requested); + const { auto, ask } = classifyWidgetCapabilities(requested); + const granted = new Set(auto); + if (ask.length === 0) return granted; + + const stored = loadWidgetConsent(this.roomId, this.widget.id, this.widget.url); + const remembered = new Set([...(stored?.allowed ?? []), ...(stored?.denied ?? [])]); + ask.forEach((r) => { + if (stored?.allowed.includes(r.capability)) granted.add(r.capability); + }); + const unanswered = ask.filter((r) => !remembered.has(r.capability)); + if (unanswered.length === 0) return granted; + + const { allowed, remember } = await this.ask(unanswered); + unanswered.forEach((r) => { + if (allowed.has(r.capability)) granted.add(r.capability); + }); + if (remember) { + saveWidgetConsent(this.roomId, this.widget.id, { + url: this.widget.url, + allowed: [ + ...(stored?.allowed ?? []), + ...unanswered.filter((r) => allowed.has(r.capability)).map((r) => r.capability), + ], + denied: [ + ...(stored?.denied ?? []), + ...unanswered.filter((r) => !allowed.has(r.capability)).map((r) => r.capability), + ], + }); + } + return granted; + } + + private assertOwnRoom(roomId: string | null | undefined): string { + const target = roomId || this.roomId; + if (target !== this.roomId) throw new Error('Widgets can only access their own room'); + return target; + } + + public async sendEvent( + eventType: string, + content: IContent, + stateKey: string | null = null, + targetRoomId: string | null = null, + ): Promise { + const roomId = this.assertOwnRoom(targetRoomId); + let r: { event_id: string }; + if (typeof stateKey === 'string') { + if (PROTECTED_STATE_TYPES.has(eventType)) { + throw new Error(`Widgets may not change ${eventType}`); + } + r = await this.mx.sendStateEvent( + roomId, + eventType as keyof StateEvents, + content as StateEvents[keyof StateEvents], + stateKey, + ); + } else if (eventType === EventType.RoomRedaction) { + r = await this.mx.redactEvent(roomId, content.redacts); + } else { + r = await this.mx.sendEvent( + roomId, + eventType as keyof TimelineEvents, + content as TimelineEvents[keyof TimelineEvents], + ); + } + return { roomId, eventId: r.event_id }; + } + + public async readRoomTimeline( + roomId: string, + eventType: string, + msgtype: string | undefined, + stateKey: string | undefined, + limit: number, + since: string | undefined, + ): Promise { + const room = this.mx.getRoom(this.assertOwnRoom(roomId)); + if (!room) return []; + const max = limit > 0 ? limit : Number.MAX_SAFE_INTEGER; + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); + for (let i = events.length - 1; i >= 0 && results.length < max; i -= 1) { + const ev = events[i]; + if (since !== undefined && ev.getId() === since) break; + if ( + ev.getType() === eventType && + !ev.isState() && + (eventType !== EventType.RoomMessage || !msgtype || msgtype === ev.getContent().msgtype) && + (ev.getStateKey() === undefined || stateKey === undefined || ev.getStateKey() === stateKey) + ) { + results.push(ev); + } + } + return results.map((e) => e.getEffectiveEvent() as IRoomEvent); + } + + public async readRoomState( + roomId: string, + eventType: string, + stateKey: string | undefined, + ): Promise { + const room = this.mx.getRoom(this.assertOwnRoom(roomId)); + const state = room?.getLiveTimeline().getState(Direction.Forward); + if (!state) return []; + if (stateKey === undefined) { + return state.getStateEvents(eventType).map((e) => e.getEffectiveEvent() as IRoomEvent); + } + const ev = state.getStateEvents(eventType, stateKey); + return ev ? [ev.getEffectiveEvent() as IRoomEvent] : []; + } + + public async readEventRelations( + eventId: string, + roomId?: string, + relationType?: string, + eventType?: string, + from?: string, + to?: string, + limit?: number, + direction?: 'f' | 'b', + ): Promise { + const target = this.assertOwnRoom(roomId); + const { events, nextBatch, prevBatch } = await this.mx.relations( + target, + eventId, + relationType ?? null, + eventType ?? null, + { from, to, limit, dir: direction as Direction }, + ); + return { + chunk: events.map((e) => e.getEffectiveEvent() as IRoomEvent), + nextBatch: nextBatch ?? undefined, + prevBatch: prevBatch ?? undefined, + }; + } + + public getKnownRooms(): string[] { + return [this.roomId]; } } diff --git a/src/app/features/room/widgets/RoomWidgetView.tsx b/src/app/features/room/widgets/RoomWidgetView.tsx index 8ec9bdfbc..4a0bbf838 100644 --- a/src/app/features/room/widgets/RoomWidgetView.tsx +++ b/src/app/features/room/widgets/RoomWidgetView.tsx @@ -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, 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(); 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, 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 ; + const addedBy = widget.creatorUserId + ? (room.getMember(widget.creatorUserId)?.rawDisplayName ?? widget.creatorUserId) + : undefined; + + return ( + <> + + {pendingAsk && ( + + )} + + ); } diff --git a/src/app/features/room/widgets/WidgetPermissionPrompt.tsx b/src/app/features/room/widgets/WidgetPermissionPrompt.tsx new file mode 100644 index 000000000..78984f737 --- /dev/null +++ b/src/app/features/room/widgets/WidgetPermissionPrompt.tsx @@ -0,0 +1,156 @@ +import React, { useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Checkbox, + Dialog, + Header, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, + color, + config, +} from 'folds'; +import type { Capability } from 'matrix-widget-api'; +import type { WidgetPermissionRequest } from './widgetUtils'; +import { useModalStyle } from '../../../hooks/useModalStyle'; + +type WidgetPermissionPromptProps = { + widgetName: string; + /** Hostname the widget is served from — who actually gets the access. */ + origin: string; + /** Who added the widget to the room, if known. */ + addedBy?: string; + requests: WidgetPermissionRequest[]; + onDone: (allowed: Set, remember: boolean) => void; +}; + +/** + * Asks before a room widget may read or send events in this room as you. + * Reading is pre-ticked, sending is not; "Deny" (or Escape / clicking away) + * grants nothing beyond display-only access. + */ +export function WidgetPermissionPrompt({ + widgetName, + origin, + addedBy, + requests, + onDone, +}: WidgetPermissionPromptProps) { + const modalStyle = useModalStyle(460); + const [checked, setChecked] = useState>( + () => new Set(requests.filter((r) => !r.sends).map((r) => r.capability)), + ); + const [remember, setRemember] = useState(true); + + const toggle = (cap: Capability, on: boolean) => + setChecked((prev) => { + const next = new Set(prev); + if (on) next.add(cap); + else next.delete(cap); + return next; + }); + + return ( + }> + + onDone(new Set(), false), + clickOutsideDeactivates: true, + escapeDeactivates: true, + }} + > + +
+ + Allow “{widgetName}” to access this room? + +
+ + + This widget is run by {origin} + {addedBy ? `, added to the room by ${addedBy}` : ''}. It is asking to act for you in + this room. Only allow what you trust {origin} with. + + + {requests.map((r) => ( + + + ) => + toggle(r.capability, e.currentTarget.checked) + } + /> + + {r.label} + {r.sends && ( + + {' '} + · as you + + )} + + + + ))} + + + ) => + setRemember(e.currentTarget.checked) + } + /> + Remember my choice for this widget on this device + + + + + + +
+
+
+
+ ); +} diff --git a/src/app/features/room/widgets/widgetUtils.test.ts b/src/app/features/room/widgets/widgetUtils.test.ts index 9d5a9f812..ec8f6d381 100644 --- a/src/app/features/room/widgets/widgetUtils.test.ts +++ b/src/app/features/room/widgets/widgetUtils.test.ts @@ -6,6 +6,8 @@ import { isWidgetUrlSafe, filterWidgetCapabilities, generateWidgetId, + classifyWidgetCapabilities, + loadWidgetConsent, } from './widgetUtils'; const APP = 'https://chat.lotusguild.org'; @@ -47,3 +49,60 @@ test('generateWidgetId is prefixed and unique across calls', () => { assert.match(a, /^lotus_/); assert.notEqual(a, b); }); + +test('classifyWidgetCapabilities: display caps are automatic, event caps are asked', () => { + const { auto, ask } = classifyWidgetCapabilities([ + MatrixCapabilities.AlwaysOnScreen, + 'org.matrix.msc2762.receive.event:m.room.message#m.text', + 'org.matrix.msc2762.send.event:m.reaction', + 'org.matrix.msc2762.receive.state_event:m.room.topic', + ]); + assert.deepEqual([...auto], [MatrixCapabilities.AlwaysOnScreen]); + assert.deepEqual( + ask.map((r) => [r.label, r.sends]), + [ + ['See messages of type “m.text” in this room', false], + ['Send reactions in this room', true], + ['See the room topic in this room', false], + ], + ); +}); + +test('classifyWidgetCapabilities never offers protected state, other rooms, to-device or account data', () => { + const { auto, ask } = classifyWidgetCapabilities([ + 'org.matrix.msc2762.send.state_event:m.room.power_levels', + 'org.matrix.msc2762.send.state_event:m.room.join_rules', + 'org.matrix.msc2762.send.state_event:m.room.encryption', + 'org.matrix.msc2762.send.state_event:m.room.member#@a:x', + 'org.matrix.msc2762.timeline:*', + 'org.matrix.msc2762.timeline:!other:x', + 'org.matrix.msc3819.send.to_device:m.custom', + 'org.matrix.msc3819.receive.to_device:m.custom', + 'org.matrix.msc4157.send.delayed_event', + 'org.matrix.msc4039.upload_file', + 'org.matrix.msc3973.user_directory_search', + 'org.matrix.msc2762.receive.room_account_data:m.fully_read', + ]); + assert.equal(auto.size, 0); + assert.deepEqual(ask, []); +}); + +test('classifyWidgetCapabilities: reading protected state is fine, changing unprotected state is asked', () => { + const { ask } = classifyWidgetCapabilities([ + 'org.matrix.msc2762.receive.state_event:m.room.power_levels', + 'org.matrix.msc2762.send.state_event:m.room.topic', + 'org.matrix.msc2762.send.state_event:com.example.board#main', + ]); + assert.deepEqual( + ask.map((r) => r.label), + [ + 'See room permissions in this room', + 'Change the room topic', + 'Send “com.example.board” state (key “main”) in this room', + ], + ); +}); + +test('loadWidgetConsent tolerates missing storage', () => { + assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined); +}); diff --git a/src/app/features/room/widgets/widgetUtils.ts b/src/app/features/room/widgets/widgetUtils.ts index 86bf3e861..3a3c09961 100644 --- a/src/app/features/room/widgets/widgetUtils.ts +++ b/src/app/features/room/widgets/widgetUtils.ts @@ -1,9 +1,16 @@ -import { Capability, MatrixCapabilities } from 'matrix-widget-api'; +import { + Capability, + EventDirection, + EventKind, + MatrixCapabilities, + WidgetEventCapability, +} 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). +// Capability policy. Benign display capabilities are granted silently. Reading +// or sending events/state in the widget's OWN room can be granted by the user +// through the consent prompt (see classifyWidgetCapabilities). Everything else +// (other rooms' timelines, to-device, account data, uploads, user directory, +// delayed events, TURN servers) is always denied. export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet = new Set([ MatrixCapabilities.AlwaysOnScreen, MatrixCapabilities.RequiresClient, @@ -43,3 +50,122 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean export const generateWidgetId = (): string => `lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; + +// State a widget may never write with the user's authority, even if approved: +// these change who can do what in the room, or its encryption/identity. +export const PROTECTED_STATE_TYPES: ReadonlySet = new Set([ + 'm.room.create', + 'm.room.power_levels', + 'm.room.join_rules', + 'm.room.history_visibility', + 'm.room.guest_access', + 'm.room.encryption', + 'm.room.member', + 'm.room.server_acl', + 'm.room.tombstone', + 'm.room.canonical_alias', + 'm.room.third_party_invite', + 'im.vector.modular.widgets', + 'm.widget', +]); + +export type WidgetPermissionRequest = { + capability: Capability; + /** Plain-language description, e.g. "Send messages". */ + label: string; + /** Sending as you (vs only reading). Shown with a warning tone. */ + sends: boolean; +}; + +export type ClassifiedCapabilities = { + /** Granted without asking. */ + auto: Set; + /** Needs the user's OK. */ + ask: WidgetPermissionRequest[]; +}; + +const FRIENDLY_EVENT_NAMES: Record = { + 'm.room.message': 'messages', + 'm.reaction': 'reactions', + 'm.sticker': 'stickers', + 'm.room.redaction': 'message deletions', + 'm.room.name': 'the room name', + 'm.room.topic': 'the room topic', + 'm.room.avatar': 'the room avatar', + 'm.room.pinned_events': 'pinned messages', + 'm.room.member': 'the member list', + 'm.room.power_levels': 'room permissions', +}; + +const describeEventCapability = (cap: WidgetEventCapability): string => { + const verb = cap.direction === EventDirection.Send ? 'Send' : 'See'; + const friendly = FRIENDLY_EVENT_NAMES[cap.eventType]; + const what = + friendly ?? `“${cap.eventType}” ${cap.kind === EventKind.State ? 'state' : 'events'}`; + let detail = ''; + if (cap.keyStr) { + detail = cap.kind === EventKind.State ? ` (key “${cap.keyStr}”)` : ` of type “${cap.keyStr}”`; + } + if (cap.kind === EventKind.State && cap.direction === EventDirection.Send && friendly) { + return `Change ${friendly}${detail}`; + } + return `${verb} ${what}${detail} in this room`; +}; + +/** + * Split a widget's requested capabilities into those granted silently and + * those the user may approve. Anything in neither list is denied outright. + */ +export const classifyWidgetCapabilities = ( + requested: Iterable, +): ClassifiedCapabilities => { + const list = [...requested]; + const auto = new Set(list.filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap))); + const ask: WidgetPermissionRequest[] = []; + WidgetEventCapability.findEventCapabilities(list).forEach((cap) => { + if (cap.kind !== EventKind.Event && cap.kind !== EventKind.State) return; + const sends = cap.direction === EventDirection.Send; + if (sends && cap.kind === EventKind.State && PROTECTED_STATE_TYPES.has(cap.eventType)) return; + ask.push({ capability: cap.raw, label: describeEventCapability(cap), sends }); + }); + return { auto, ask }; +}; + +/** + * Remembered approvals, per viewer (localStorage), keyed by room + widget id and + * tied to the widget's URL: if the URL changes, the widget is asked again. + */ +export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] }; + +export const widgetConsentKey = (roomId: string, widgetId: string): string => + `lotus.widgetConsent.${roomId}.${widgetId}`; + +export const loadWidgetConsent = ( + roomId: string, + widgetId: string, + url: string, +): StoredWidgetConsent | undefined => { + try { + const raw = localStorage.getItem(widgetConsentKey(roomId, widgetId)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as StoredWidgetConsent; + if (parsed.url !== url || !Array.isArray(parsed.allowed) || !Array.isArray(parsed.denied)) { + return undefined; + } + return parsed; + } catch { + return undefined; + } +}; + +export const saveWidgetConsent = ( + roomId: string, + widgetId: string, + consent: StoredWidgetConsent, +) => { + try { + localStorage.setItem(widgetConsentKey(roomId, widgetId), JSON.stringify(consent)); + } catch { + // Storage unavailable: the widget just asks again next time. + } +};