fix(security): logout's search-index wipe coordinates across tabs
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
deleteSearchCacheDatabase() resolved after a 3 s "blocked" timeout while another tab still held the DB, so decrypted rows could survive logout. It now broadcasts lotus-logout first; every tab closes its handle and refuses to reopen, then the delete proceeds. A boot with no session re-runs the wipe once in case a race was still lost. Unit-tested. Fixes #45 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -55,7 +55,50 @@ const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [
|
||||
|
||||
let dbPromise: Promise<IDBDatabase | null> | null = null;
|
||||
|
||||
// Gitea #45 — `deleteSearchCacheDatabase()` used to resolve as soon as its
|
||||
// bounded `onblocked` wait elapsed, even though another tab still had the DB
|
||||
// open, so the delete stayed silently queued while that tab could keep
|
||||
// `put`-ing fresh decrypted rows (or immediately reopen the DB once the
|
||||
// delete eventually landed). `closed` is broadcast to every tab the moment a
|
||||
// wipe starts: once set, `openDb()` refuses to (re)open a handle — including
|
||||
// for an already in-flight `saveRoomIndex()` awaiting its next IDB
|
||||
// round-trip — so nothing can repopulate the DB out from under the delete.
|
||||
let closed = false;
|
||||
|
||||
const LOGOUT_CHANNEL_NAME = 'lotus-logout';
|
||||
const LOGOUT_MESSAGE = 'lotus-logout';
|
||||
|
||||
/** Stop touching the DB and release our handle. Idempotent, never throws. */
|
||||
const handleLogoutSignal = (): void => {
|
||||
closed = true;
|
||||
const pending = dbPromise;
|
||||
dbPromise = null;
|
||||
if (pending) {
|
||||
pending.then((db) => db?.close()).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const logoutChannel: BroadcastChannel | null = (() => {
|
||||
try {
|
||||
if (typeof BroadcastChannel === 'undefined') return null;
|
||||
const channel = new BroadcastChannel(LOGOUT_CHANNEL_NAME);
|
||||
channel.onmessage = (ev: MessageEvent) => {
|
||||
if (ev.data === LOGOUT_MESSAGE) handleLogoutSignal();
|
||||
};
|
||||
// Node's BroadcastChannel (unlike the browser's) keeps the event loop
|
||||
// alive while open, which would hang `node --test`. unref() is a
|
||||
// Node-only extension — no-op via optional chaining in the browser.
|
||||
(channel as unknown as { unref?: () => void }).unref?.();
|
||||
return channel;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
const openDb = (): Promise<IDBDatabase | null> => {
|
||||
// Once logout has broadcast a wipe, this module must never reopen the DB —
|
||||
// otherwise a write racing the delete could recreate it right after.
|
||||
if (closed) return Promise.resolve(null);
|
||||
if (dbPromise) return dbPromise;
|
||||
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
|
||||
try {
|
||||
@@ -335,8 +378,23 @@ export const clearAll = async (): Promise<void> => {
|
||||
* Drop the entire on-disk database. Wired into the logout path by the
|
||||
* coordinator (initMatrix) so no decrypted plaintext lingers after sign-out.
|
||||
* Closes any open handle first so the delete is not blocked. Never throws.
|
||||
*
|
||||
* Gitea #45 — before touching IDB at all, broadcast the wipe on
|
||||
* `lotus-logout` so every other tab closes its handle and stops writing
|
||||
* (`handleLogoutSignal`/`closed` above); that tab's in-flight
|
||||
* `saveRoomIndex()` short-circuits on its next IDB call instead of
|
||||
* repopulating a DB we're about to delete or racing back in right after. The
|
||||
* bounded `onblocked` wait stays as a last-resort fallback (a tab that hasn't
|
||||
* processed the broadcast yet, or a browser without BroadcastChannel), not
|
||||
* the primary coordination mechanism.
|
||||
*/
|
||||
export const deleteSearchCacheDatabase = async (): Promise<void> => {
|
||||
closed = true;
|
||||
try {
|
||||
logoutChannel?.postMessage(LOGOUT_MESSAGE);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
const existing = dbPromise ? await dbPromise : null;
|
||||
if (existing) existing.close();
|
||||
@@ -373,3 +431,15 @@ export const deleteSearchCacheDatabase = async (): Promise<void> => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure decision for the boot-time completion wipe (Gitea #45): a delete that
|
||||
* lost the `onblocked` race leaves `lotus-search-cache` on disk even though
|
||||
* the user is signed out. Re-run the wipe next boot, but only when there is
|
||||
* no session (never nuke a live, signed-in cache) and only once per boot
|
||||
* (the caller's own re-checks — e.g. route changes — must not repeat it).
|
||||
* Exported for testing without a DOM/session; the actual boot wiring lives in
|
||||
* initMatrix.ts.
|
||||
*/
|
||||
export const shouldRunBootCleanup = (hasSession: boolean, alreadyRan: boolean): boolean =>
|
||||
!hasSession && !alreadyRan;
|
||||
|
||||
Reference in New Issue
Block a user