fix(security): persistent search index forgets redacted and left-room text
Nothing ever removed an indexed row: redacted messages stayed searchable with full plaintext and rendered as normal results. Now: a client-level RoomEvent.Redaction listener deletes the row, leave/ban clears the room (clearRoom finally has a caller), m.replace edits upsert the original row instead of indexing the "* fallback" separately, and cached rows whose local event is redacted render through the existing redacted_because placeholder. Unit-tested. Fixes #14 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
saveRoomIndex,
|
||||
clearRoom,
|
||||
clearAll,
|
||||
deleteRow,
|
||||
deleteSearchCacheDatabase,
|
||||
SearchCacheRow,
|
||||
} from './searchCache';
|
||||
@@ -128,6 +129,31 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
|
||||
await deleteSearchCacheDatabase();
|
||||
});
|
||||
|
||||
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
|
||||
await clearAll();
|
||||
const rows: SearchCacheRow[] = [
|
||||
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello' },
|
||||
{ roomId: '!r1', eventId: '$2', ts: 200, sender: '@b', body: 'world' },
|
||||
{ roomId: '!r2', eventId: '$1', ts: 300, sender: '@a', body: 'other room, same id' },
|
||||
];
|
||||
await putRows(rows);
|
||||
|
||||
await deleteRow('!r1', '$1');
|
||||
|
||||
const r1 = await queryRoom('!r1');
|
||||
assert.deepEqual(
|
||||
r1.map((x) => x.eventId),
|
||||
['$2'],
|
||||
);
|
||||
// A same-eventId row in a different room is untouched (composite key).
|
||||
assert.equal((await queryRoom('!r2')).length, 1);
|
||||
|
||||
// Deleting a row that doesn't exist is a silent no-op.
|
||||
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
|
||||
|
||||
await deleteSearchCacheDatabase();
|
||||
});
|
||||
|
||||
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
||||
// In this environment IndexedDB is absent; every call must degrade to a
|
||||
// cache-miss rather than throwing.
|
||||
@@ -138,6 +164,7 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
|
||||
assert.equal(await getCoverage('!r'), null);
|
||||
await assert.doesNotReject(saveRoomIndex('!r', []));
|
||||
await assert.doesNotReject(clearRoom('!r'));
|
||||
await assert.doesNotReject(deleteRow('!r', '$1'));
|
||||
await assert.doesNotReject(clearAll());
|
||||
await assert.doesNotReject(deleteSearchCacheDatabase());
|
||||
});
|
||||
|
||||
@@ -288,6 +288,23 @@ export const mergeSearchResults = <
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a single cached row, e.g. because its event was redacted. Gitea #14:
|
||||
* without this, a redaction only ever removed the in-memory hit — the
|
||||
* decrypted plaintext stayed in IndexedDB forever.
|
||||
*/
|
||||
export const deleteRow = async (roomId: string, eventId: string): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
try {
|
||||
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
||||
tx.objectStore(MESSAGES_STORE).delete([roomId, eventId]);
|
||||
await awaitTx(tx);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const clearRoom = async (roomId: string): Promise<void> => {
|
||||
const db = await openDb();
|
||||
if (!db) return;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { searchCacheEnabledAtom } from '../state/searchCacheEnabled';
|
||||
import { clearRoom, deleteRow } from './searchCache';
|
||||
|
||||
/**
|
||||
* Gitea #14 — the persistent search cache (`searchCache.ts`) had no
|
||||
* invalidation path other than logout or the manual "Clear cached index"
|
||||
* button, so redacted messages and rooms the user left kept their decrypted
|
||||
* plaintext searchable on disk indefinitely.
|
||||
*
|
||||
* While the cache is enabled, listen client-wide for redactions (delete the
|
||||
* redacted row) and for leaving/being banned from a room (wipe the room's
|
||||
* cached rows via the existing `clearRoom`).
|
||||
*/
|
||||
export const useSearchCacheInvalidation = (): void => {
|
||||
const mx = useMatrixClient();
|
||||
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheEnabled) return undefined;
|
||||
|
||||
const onRedaction = (event: MatrixEvent, room: Room) => {
|
||||
const redactedEventId = event.getAssociatedId();
|
||||
if (redactedEventId) deleteRow(room.roomId, redactedEventId);
|
||||
};
|
||||
|
||||
const onMyMembership = (room: Room, membership: string) => {
|
||||
if (membership === 'leave' || membership === 'ban') {
|
||||
clearRoom(room.roomId);
|
||||
}
|
||||
};
|
||||
|
||||
mx.on(RoomEvent.Redaction, onRedaction);
|
||||
mx.on(RoomEvent.MyMembership, onMyMembership);
|
||||
return () => {
|
||||
mx.off(RoomEvent.Redaction, onRedaction);
|
||||
mx.off(RoomEvent.MyMembership, onMyMembership);
|
||||
};
|
||||
}, [mx, cacheEnabled]);
|
||||
};
|
||||
Reference in New Issue
Block a user