diff --git a/src/app/features/room/widgets/widgetUtils.test.ts b/src/app/features/room/widgets/widgetUtils.test.ts index ec8f6d381..58e0eeecb 100644 --- a/src/app/features/room/widgets/widgetUtils.test.ts +++ b/src/app/features/room/widgets/widgetUtils.test.ts @@ -106,3 +106,19 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr test('loadWidgetConsent tolerates missing storage', () => { assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined); }); + +test('[#266] space structure, call membership and Lotus room policy can never be granted', () => { + const { auto, ask } = classifyWidgetCapabilities( + [ + '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', + ].map((t) => `org.matrix.msc2762.send.state_event:${t}`), + ); + assert.equal(auto.size, 0); + assert.deepEqual(ask, []); +}); diff --git a/src/app/features/room/widgets/widgetUtils.ts b/src/app/features/room/widgets/widgetUtils.ts index 3a3c09961..09cf85267 100644 --- a/src/app/features/room/widgets/widgetUtils.ts +++ b/src/app/features/room/widgets/widgetUtils.ts @@ -67,6 +67,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet = new Set([ '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 = { @@ -137,8 +147,11 @@ export const classifyWidgetCapabilities = ( */ 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 => - `lotus.widgetConsent.${roomId}.${widgetId}`; + `${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`; export const loadWidgetConsent = ( roomId: string, diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index f83d6bd86..486217c10 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -33,6 +33,8 @@ import { } from '../../../client/initMatrix'; import { deleteSearchCacheDatabase } from '../../utils/searchCache'; import { clearPlaintextCaches } from '../../state/plaintextCaches'; +import { clearSoundboardClipCache } from '../../utils/soundboardClips'; +import { clearSharedPayload } from '../../../swShare'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; @@ -172,6 +174,10 @@ const useLogoutListener = (mx?: MatrixClient) => { // (translation, scheduled messages, recent searches/forwards/gifs/ // stickers, nav paths) — wipe them on server-forced logout too. clearPlaintextCaches(mx?.getUserId() ?? undefined); + // [Gitea #57] As on manual logout: no decrypted clip bytes via blob: URLs. + clearSoundboardClipCache(); + // [Gitea #267] As on manual logout: drop files stashed by the share target. + await clearSharedPayload().catch(() => undefined); // Remove only the session credential keys — NOT settings, drafts, and // other preferences (N98). The SDK's IndexedDB stores are cleared above; // window.localStorage.clear() is reserved for the explicit reset path. diff --git a/src/app/pages/client/share/Share.tsx b/src/app/pages/client/share/Share.tsx index 9e04fffd7..d6a6cff76 100644 --- a/src/app/pages/client/share/Share.tsx +++ b/src/app/pages/client/share/Share.tsx @@ -145,6 +145,11 @@ export function Share() { Shared + {/* [Gitea #267] Not everything that arrives here came from the + user's share sheet; say so before they pick a room. */} + + This came from another app or website. Check it before you send it. + {shared.files.map((f) => ( { store.clear(); @@ -104,3 +107,17 @@ test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false); assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads'); }); + +test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => { + assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync'); + store.clear(); + const consent = widgetConsentKey('!room:server', 'widget-1'); + store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}'); + store.set('lotus.widgetConsentNotAKey', 'kept'); + removed.length = 0; + + clearPlaintextCaches('@me:server'); + + assert.ok(removed.includes(consent), 'consent cleared'); + assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept'); +}); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index ca4c9e8a0..831435b24 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -139,6 +139,31 @@ const clearAccountScopedFlags = (userId?: string): void => { }); }; +/** + * [Gitea #266] Remembered widget approvals (`lotus.widgetConsent..`, + * widgetUtils.ts) would otherwise apply silently to the next account on this + * device, and their keys list the previous account's rooms. Kept as a string + * here so logout doesn't pull the widget code into the entry chunk; a test + * checks it matches WIDGET_CONSENT_PREFIX. + */ +export const WIDGET_CONSENT_KEY_PREFIX = 'lotus.widgetConsent.'; +const clearWidgetConsents = (): void => { + let keys: string[]; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + keys.forEach((key) => { + if (!key.startsWith(WIDGET_CONSENT_KEY_PREFIX)) return; + try { + localStorage.removeItem(key); + } catch { + // best-effort + } + }); +}; + export const clearPlaintextCaches = (userId?: string): void => { wiped = true; clearTranslationCache(); @@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => { clearStatusMessage(); clearCallVolumesAndSyncMeta(userId); clearAccountScopedFlags(userId); + clearWidgetConsents(); if (userId) clearNavToActivePathStore(userId); }; diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 605d0f300..14f418556 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -9,6 +9,7 @@ import { pushSessionToSW } from '../sw-session'; import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache'; import { clearPlaintextCaches } from '../app/state/plaintextCaches'; import { clearSoundboardClipCache } from '../app/utils/soundboardClips'; +import { clearSharedPayload } from '../swShare'; // Thrown when the local IndexedDB has a higher schema version than this SDK expects. // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted). @@ -146,6 +147,9 @@ export const logoutClient = async (mx: MatrixClient) => { // [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard // clip bytes stay reachable via live blob: URLs until the reload otherwise. clearSoundboardClipCache(); + // [Gitea #267] Files stashed by the share target stay in Cache Storage until + // /share is opened; a shared photo must not outlive the logout. + await clearSharedPayload().catch(() => undefined); // Remove only the session credential keys, preserving user preferences and // unsent drafts (N98). The factory-reset path is clearLoginData() below. removeFallbackSession(); diff --git a/src/sw.ts b/src/sw.ts index 33d9a8e64..3be49b5e2 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,7 +1,7 @@ /// import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; -import { SHARE_TARGET_PATH, stashSharedForm } from './swShare'; +import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare'; import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply'; export type {}; @@ -249,6 +249,11 @@ self.addEventListener('fetch', (event: FetchEvent) => { // share_target). Stash the form and bounce to the in-app /share page. if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) { const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length); + // [Gitea #267] A cross-site form posting here is not a share: drop it. + if (!isTrustedShareRequest(event.request.headers, self.location.origin)) { + event.respondWith(Response.redirect(`${base}/`, 303)); + return; + } event.respondWith( stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)), ); diff --git a/src/swShare.test.ts b/src/swShare.test.ts index 69147e063..a9c7e0dc9 100644 --- a/src/swShare.test.ts +++ b/src/swShare.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { shareDraftText } from './swShare'; +import { isTrustedShareRequest, shareDraftText } from './swShare'; const base = { files: [], receivedAt: 0 }; @@ -25,3 +25,14 @@ test('shareDraftText drops a title/url the text already contains', () => { assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test'); assert.equal(shareDraftText(base), ''); }); + +test('[#267] a share another website submitted is refused', () => { + const app = 'https://chat.lotusguild.org'; + const h = (origin?: string) => new Headers(origin ? { Origin: origin } : {}); + assert.equal(isTrustedShareRequest(h(), app), true, 'no Origin (OS share sheet)'); + assert.equal(isTrustedShareRequest(h('null'), app), true, 'opaque origin (OS share sheet)'); + assert.equal(isTrustedShareRequest(h(app), app), true, 'the app itself'); + assert.equal(isTrustedShareRequest(h('https://evil.example'), app), false); + assert.equal(isTrustedShareRequest(h('https://chat.lotusguild.org.evil.example'), app), false); + assert.equal(isTrustedShareRequest(h('http://chat.lotusguild.org'), app), false, 'other scheme'); +}); diff --git a/src/swShare.ts b/src/swShare.ts index f8b632a04..429a45540 100644 --- a/src/swShare.ts +++ b/src/swShare.ts @@ -20,6 +20,27 @@ export type SharePayload = { receivedAt: number; }; +/** + * [Gitea #267] Refuse a share that another website submitted: any site could + * otherwise auto-submit a cross-site form here and drop attacker-chosen text + * and files into the "Share to…" picker as if the user had shared them. + * + * The worker can't use `Sec-Fetch-Site`: fetch-metadata headers are added + * after the service worker and never appear on `event.request` (checked in + * Chromium). `Origin` does: a cross-site form carries the site's origin. The + * OS share sheet's POST has no web initiator, so its Origin is `null` or + * absent, and both stay accepted. A page can also produce `Origin: null` + * (sandboxed frame, no-referrer), which is why /share also says the content + * came from elsewhere. + */ +export const isTrustedShareRequest = ( + headers: Pick, + selfOrigin: string, +): boolean => { + const origin = headers.get('Origin'); + return origin === null || origin === 'null' || origin === selfOrigin; +}; + /** Worker side: stash the POSTed form and answer with a redirect to /share. */ export async function stashSharedForm(request: Request, base: string): Promise { const form = await request.formData();