Files
cinny/src/app/features/room/widgets/widgetUtils.test.ts
T
Lotus CIandClaude Opus 5.5 c1661e48fa
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
feat(widgets): ask before a room widget may read or send in the room (#205)
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
2026-09-24 21:17:18 -04:00

109 lines
4.1 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixCapabilities, Capability } from 'matrix-widget-api';
import {
validateWidgetUrl,
isWidgetUrlSafe,
filterWidgetCapabilities,
generateWidgetId,
classifyWidgetCapabilities,
loadWidgetConsent,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
test('validateWidgetUrl accepts a cross-origin https url', () => {
assert.equal(validateWidgetUrl('https://pad.example.org/p/room', APP), undefined);
});
test('validateWidgetUrl rejects empty / invalid / http / same-origin', () => {
assert.equal(validateWidgetUrl(' ', APP), 'empty');
assert.equal(validateWidgetUrl('not a url', APP), 'invalid');
assert.equal(validateWidgetUrl('http://example.org', APP), 'not-https');
assert.equal(validateWidgetUrl('https://chat.lotusguild.org/evil', APP), 'same-origin');
});
test('isWidgetUrlSafe rejects same-origin + garbage, accepts cross-origin', () => {
assert.equal(isWidgetUrlSafe('https://chat.lotusguild.org/x', APP), false);
assert.equal(isWidgetUrlSafe('https://other.example/x', APP), true);
assert.equal(isWidgetUrlSafe('garbage', APP), false);
});
test('filterWidgetCapabilities keeps only the benign allowlist', () => {
const requested = new Set<Capability>([
MatrixCapabilities.AlwaysOnScreen,
'm.send.event:m.room.message',
'org.matrix.msc2762.receive.state_event:m.room.member',
MatrixCapabilities.Screenshots,
]);
const allowed = filterWidgetCapabilities(requested);
assert.ok(allowed.has(MatrixCapabilities.AlwaysOnScreen));
assert.ok(allowed.has(MatrixCapabilities.Screenshots));
assert.equal(allowed.has('m.send.event:m.room.message'), false);
assert.equal(allowed.size, 2);
});
test('generateWidgetId is prefixed and unique across calls', () => {
const a = generateWidgetId();
const b = generateWidgetId();
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);
});