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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -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';
|
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
|
// 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<string, string>();
|
const objectUrlCache = new Map<string, string>();
|
||||||
|
/** 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
|
* Resolve an mxc clip to a `blob:` object URL the Element Call widget can fetch
|
||||||
@@ -23,16 +27,42 @@ const objectUrlCache = new Map<string, string>();
|
|||||||
*/
|
*/
|
||||||
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
|
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
|
||||||
const cached = objectUrlCache.get(mxcUrl);
|
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);
|
const httpUrl = mxcUrlToHttp(mx, mxcUrl, true);
|
||||||
if (!httpUrl) throw new Error('invalid mxc url');
|
if (!httpUrl) throw new Error('invalid mxc url');
|
||||||
const blob = await downloadMedia(httpUrl);
|
const blob = await downloadMedia(httpUrl);
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
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);
|
objectUrlCache.set(mxcUrl, objectUrl);
|
||||||
return 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
|
* 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
|
* feedback — LiveKit doesn't loop a participant's own published track back to
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { revokeOidcTokens } from './oidcLogout';
|
|||||||
import { pushSessionToSW } from '../sw-session';
|
import { pushSessionToSW } from '../sw-session';
|
||||||
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
|
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
|
||||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
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.
|
// 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).
|
// 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) => {
|
export const clearCacheAndReload = async (mx: MatrixClient) => {
|
||||||
mx.stopClient();
|
mx.stopClient();
|
||||||
clearNavToActivePathStore(mx.getSafeUserId());
|
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();
|
await mx.store.deleteAllData();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
};
|
};
|
||||||
@@ -128,6 +132,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
|||||||
// scheduled messages, recent searches/forwards/gifs/stickers, nav paths) —
|
// scheduled messages, recent searches/forwards/gifs/stickers, nav paths) —
|
||||||
// wipe them too.
|
// wipe them too.
|
||||||
clearPlaintextCaches(mx.getUserId() ?? undefined);
|
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
|
// Remove only the session credential keys, preserving user preferences and
|
||||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||||
removeFallbackSession();
|
removeFallbackSession();
|
||||||
|
|||||||
Reference in New Issue
Block a user