Files
cinny/src/app/features/room/widgets/widgetUtils.ts
T
Lotus CIandClaude Opus 5.5 14f05a3d5c
CI / Build & Quality Checks (pull_request) Successful in 1m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m51s
fix(security): widget consents and shared files cleared on logout; forged shares refused (#266, #267)
#266: remembered widget approvals (lotus.widgetConsent.*) survived logout,
so they applied silently to the next account on the device and listed the
previous account's rooms; clearPlaintextCaches now sweeps them. Space
structure, call membership and Lotus room-policy state join
PROTECTED_STATE_TYPES, so no widget can be granted them.

#267: any website could auto-submit a form to /share-target and land the
user in the share picker with attacker-chosen text and files. The worker
now refuses a share whose Origin is another site. (Sec-Fetch-Site, as the
issue suggested, never reaches a service worker: checked in Chromium.)
Origin null/absent, as the OS share sheet sends, stays accepted, and
/share now says the content came from another app or website. Both logout
paths await clearSharedPayload(); the server-forced one also clears the
soundboard clip cache, as #57 intended.

Fixes #266
Fixes #267

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:47:11 -04:00

185 lines
6.4 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',
// [Gitea #266] Space structure, call membership and Lotus room policy: a
// widget could restructure a space, fake call membership or lift the
// voice-channel limit, so these can't be granted even with consent.
'm.space.child',
'm.space.parent',
'org.matrix.msc3401.call',
'org.matrix.msc3401.call.member',
'm.call.member',
'io.lotus.voice_limit',
'io.lotus.room_quality',
]);
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[] };
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
// approvals never apply to the next account on the same device.
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`${WIDGET_CONSENT_PREFIX}${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.
}
};