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
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:
co-authored by
Claude Opus 5.5
parent
c96c47dd0d
commit
c1661e48fa
@@ -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<Capability>; 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<Capability>): Promise<Set<Capability>> {
|
||||
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<ISendEventDetails> {
|
||||
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<IRoomEvent[]> {
|
||||
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<IRoomEvent[]> {
|
||||
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<IReadEventRelationsResult> {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Capability>, 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<Set<Capability>>(
|
||||
() => 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 (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#widget-perm-deny',
|
||||
onDeactivate: () => onDone(new Set(), false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: true,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
variant="Surface"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="widget-perm-title"
|
||||
aria-describedby="widget-perm-body"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Text as="h2" size="H4" id="widget-perm-title" truncate>
|
||||
Allow “{widgetName}” to access this room?
|
||||
</Text>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text priority="400" id="widget-perm-body">
|
||||
This widget is run by <b>{origin}</b>
|
||||
{addedBy ? `, added to the room by ${addedBy}` : ''}. It is asking to act for you in
|
||||
this room. Only allow what you trust {origin} with.
|
||||
</Text>
|
||||
<Box as="ul" direction="Column" gap="200" style={{ margin: 0, padding: 0 }}>
|
||||
{requests.map((r) => (
|
||||
<Box
|
||||
as="li"
|
||||
key={r.capability}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ listStyle: 'none' }}
|
||||
>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Checkbox
|
||||
size="300"
|
||||
variant={r.sends ? 'Warning' : 'Primary'}
|
||||
checked={checked.has(r.capability)}
|
||||
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
|
||||
toggle(r.capability, e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<Text size="T300">
|
||||
{r.label}
|
||||
{r.sends && (
|
||||
<Text as="span" size="T200" style={{ color: color.Warning.Main }}>
|
||||
{' '}
|
||||
· as you
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Checkbox
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
checked={remember}
|
||||
onClick={(e: React.MouseEvent<HTMLInputElement>) =>
|
||||
setRemember(e.currentTarget.checked)
|
||||
}
|
||||
/>
|
||||
<Text size="T300">Remember my choice for this widget on this device</Text>
|
||||
</Box>
|
||||
<Box gap="200" justifyContent="End">
|
||||
<Button
|
||||
id="widget-perm-deny"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={() => onDone(new Set(), remember)}
|
||||
>
|
||||
<Text size="B400">Deny</Text>
|
||||
</Button>
|
||||
<Button variant="Primary" onClick={() => onDone(new Set(checked), remember)}>
|
||||
<Text size="B400">
|
||||
{checked.size === 0 ? 'Continue without access' : 'Allow selected'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<Capability> = new Set<Capability>([
|
||||
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<string> = 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<Capability>;
|
||||
/** Needs the user's OK. */
|
||||
ask: WidgetPermissionRequest[];
|
||||
};
|
||||
|
||||
const FRIENDLY_EVENT_NAMES: Record<string, string> = {
|
||||
'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<Capability>,
|
||||
): 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.
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user