From ac0ec9f42dcb5fd135d35924027fac153b0489ad Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 12 Sep 2026 20:28:41 -0400 Subject: [PATCH] fix(security): revoke soundboard blob URLs on logout; cap the cache Decrypted clip blob: URLs lived in an unbounded module Map for the page lifetime and survived logout. Add clearSoundboardClipCache() (called from both logout paths next to clearPlaintextCaches) and a 64-entry LRU that revokes on evict. Unit-tested. Fixes #57 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/utils/soundboardClips.test.ts | 86 +++++++++++++++++++++++++++ src/app/utils/soundboardClips.ts | 34 ++++++++++- src/client/initMatrix.ts | 7 +++ 3 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 src/app/utils/soundboardClips.test.ts diff --git a/src/app/utils/soundboardClips.test.ts b/src/app/utils/soundboardClips.test.ts new file mode 100644 index 000000000..abf1c7cf2 --- /dev/null +++ b/src/app/utils/soundboardClips.test.ts @@ -0,0 +1,86 @@ +import { test, beforeEach, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import type { MatrixClient } from 'matrix-js-sdk'; +import { resolveClipObjectUrl, clearSoundboardClipCache } from './soundboardClips'; + +// [Gitea #57] resolveClipObjectUrl/clearSoundboardClipCache talk to +// URL.createObjectURL/revokeObjectURL and `fetch` — neither exists in this +// Node test environment, so each case stubs minimal fakes and restores them. + +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; +const originalFetch = globalThis.fetch; + +let nextUrlId = 0; +let revoked: string[] = []; + +const fakeMx = { + getHomeserverUrl: () => 'https://example.org', +} as unknown as MatrixClient; + +beforeEach(() => { + nextUrlId = 0; + revoked = []; + (URL as unknown as { createObjectURL: (b: Blob) => string }).createObjectURL = () => + `blob:fake-${nextUrlId++}`; + (URL as unknown as { revokeObjectURL: (u: string) => void }).revokeObjectURL = (url: string) => { + revoked.push(url); + }; + globalThis.fetch = (async () => + ({ + ok: true, + blob: async () => new Blob(['clip']), + }) as Response) as typeof fetch; +}); + +afterEach(() => { + clearSoundboardClipCache(); + URL.createObjectURL = originalCreateObjectURL; + URL.revokeObjectURL = originalRevokeObjectURL; + globalThis.fetch = originalFetch; +}); + +test('caches the resolved object URL per mxc and does not re-download', async () => { + let downloads = 0; + globalThis.fetch = (async () => { + downloads += 1; + return { ok: true, blob: async () => new Blob(['clip']) } as Response; + }) as typeof fetch; + + const first = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip1'); + const second = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip1'); + + assert.equal(first, second); + assert.equal(downloads, 1); +}); + +test('clearSoundboardClipCache revokes every cached URL and empties the map', async () => { + const urlA = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/a'); + const urlB = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/b'); + + clearSoundboardClipCache(); + + assert.deepEqual(new Set(revoked), new Set([urlA, urlB])); + + // Cache is empty, so resolving the same mxc again re-downloads (new blob URL). + const urlAAgain = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/a'); + assert.notEqual(urlAAgain, urlA); +}); + +test('evicts the least-recently-used entry once the cache exceeds its cap', async () => { + // Cap is 64; fill it, then add one more and confirm exactly one eviction. + for (let i = 0; i < 64; i += 1) { + // eslint-disable-next-line no-await-in-loop + await resolveClipObjectUrl(fakeMx, `mxc://example.org/clip-${i}`); + } + assert.equal(revoked.length, 0); + + const firstUrl = await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip-0'); + // Re-resolving clip-0 above marked it as recently used, so the least-recently + // used entry is now clip-1 — adding a new clip should evict that one. + await resolveClipObjectUrl(fakeMx, 'mxc://example.org/clip-new'); + + assert.equal(revoked.length, 1); + // clip-0 must not have been evicted since it was refreshed just before. + assert.equal(revoked.includes(firstUrl), false); +}); diff --git a/src/app/utils/soundboardClips.ts b/src/app/utils/soundboardClips.ts index b4466d10a..5b01aff7f 100644 --- a/src/app/utils/soundboardClips.ts +++ b/src/app/utils/soundboardClips.ts @@ -12,8 +12,12 @@ export const SOUNDBOARD_MAX_CLIPS = 40; export const SOUNDBOARD_ACCEPT = 'audio/mpeg,audio/ogg,audio/wav,audio/webm,audio/mp4,audio/aac'; // Cache resolved object URLs per mxc so re-triggering a clip doesn't re-download -// it. Object URLs live for the page session; the set is tiny (<= MAX_CLIPS). +// it. [Gitea #57] Clips are decrypted media held live via `blob:` URLs, so the +// cache is capped LRU-style (oldest entry revoked on evict) and fully revoked +// on logout — see clearSoundboardClipCache(). const objectUrlCache = new Map(); +/** Cap is global (across every pack), not per-pack like SOUNDBOARD_MAX_CLIPS. */ +const OBJECT_URL_CACHE_MAX = 64; /** * Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch @@ -23,16 +27,42 @@ const objectUrlCache = new Map(); */ export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise => { const cached = objectUrlCache.get(mxcUrl); - if (cached) return cached; + if (cached) { + // Refresh recency: re-insert so this entry is last to be evicted. + objectUrlCache.delete(mxcUrl); + objectUrlCache.set(mxcUrl, cached); + return cached; + } const httpUrl = mxcUrlToHttp(mx, mxcUrl, true); if (!httpUrl) throw new Error('invalid mxc url'); const blob = await downloadMedia(httpUrl); const objectUrl = URL.createObjectURL(blob); + + if (objectUrlCache.size >= OBJECT_URL_CACHE_MAX) { + // Map preserves insertion order, so the first key is the least recently used. + const oldestKey = objectUrlCache.keys().next().value; + if (oldestKey !== undefined) { + const oldestUrl = objectUrlCache.get(oldestKey); + if (oldestUrl) URL.revokeObjectURL(oldestUrl); + objectUrlCache.delete(oldestKey); + } + } + objectUrlCache.set(mxcUrl, objectUrl); return objectUrl; }; +/** + * [Gitea #57] Revoke every cached soundboard clip blob URL and empty the + * cache. Decrypted clip bytes must not stay reachable past logout — call this + * from every logout/clear-cache path alongside clearPlaintextCaches(). + */ +export const clearSoundboardClipCache = (): void => { + objectUrlCache.forEach((objectUrl) => URL.revokeObjectURL(objectUrl)); + objectUrlCache.clear(); +}; + /** * Play a resolved clip locally so the person who pressed it gets immediate * feedback — LiveKit doesn't loop a participant's own published track back to diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 94574039e..b1c248ab4 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -8,6 +8,7 @@ import { revokeOidcTokens } from './oidcLogout'; import { pushSessionToSW } from '../sw-session'; import { deleteSearchCacheDatabase } from '../app/utils/searchCache'; import { clearPlaintextCaches } from '../app/state/plaintextCaches'; +import { clearSoundboardClipCache } from '../app/utils/soundboardClips'; // 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). @@ -103,6 +104,9 @@ export const startClient = async (mx: MatrixClient) => { export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); clearNavToActivePathStore(mx.getSafeUserId()); + // [Gitea #57] Soundboard clip blob URLs hold decrypted media reachable for + // the whole page session — revoke them alongside the rest of the caches. + clearSoundboardClipCache(); await mx.store.deleteAllData(); window.location.reload(); }; @@ -128,6 +132,9 @@ export const logoutClient = async (mx: MatrixClient) => { // scheduled messages, recent searches/forwards/gifs/stickers, nav paths) — // wipe them too. clearPlaintextCaches(mx.getUserId() ?? undefined); + // [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard + // clip bytes stay reachable via live blob: URLs until the reload otherwise. + clearSoundboardClipCache(); // Remove only the session credential keys, preserving user preferences and // unsent drafts (N98). The factory-reset path is clearLoginData() below. removeFallbackSession();