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
172 lines
5.9 KiB
TypeScript
172 lines
5.9 KiB
TypeScript
import {
|
|
Capability,
|
|
EventDirection,
|
|
EventKind,
|
|
MatrixCapabilities,
|
|
WidgetEventCapability,
|
|
} from 'matrix-widget-api';
|
|
|
|
// 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,
|
|
MatrixCapabilities.Screenshots,
|
|
]);
|
|
|
|
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
|
|
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
|
|
|
|
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
|
|
|
|
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
|
|
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
|
|
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return 'empty';
|
|
let url: URL;
|
|
try {
|
|
url = new URL(trimmed);
|
|
} catch {
|
|
return 'invalid';
|
|
}
|
|
if (url.protocol !== 'https:') return 'not-https';
|
|
if (url.origin === appOrigin) return 'same-origin';
|
|
return undefined;
|
|
};
|
|
|
|
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
|
|
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
|
|
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
|
|
try {
|
|
return new URL(completeUrl).origin !== appOrigin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
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.
|
|
}
|
|
};
|