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:
@@ -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<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
|
||||
@@ -23,16 +27,42 @@ const objectUrlCache = new Map<string, string>();
|
||||
*/
|
||||
export const resolveClipObjectUrl = async (mx: MatrixClient, mxcUrl: string): Promise<string> => {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user