Persistent search index keeps decrypted plaintext of redacted messages and left rooms #14

Closed
opened 2026-09-12 01:50:50 -04:00 by jared · 0 comments
Owner

Severity: high · Type: security · Confidence: high

Location: src/app/features/message-search/useLocalMessageSearch.ts:183 (memory scan skips redacted), :196-208 (persist), src/app/utils/searchCache.ts:126-140
src/app/utils/searchCache.ts:126-137 (putRows), src/app/utils/searchCache.ts:291-302 (clearRoom, never called), src/app/features/message-search/useLocalMessageSearch.ts:92-104,246-256, src/app/features/message-search/useLocalMessageSearch.ts:60-110 (rowToResultItem)

Problem

The in-memory scan correctly skips event.isRedacted(), but nothing ever removes an already-persisted row when its event is later redacted (there is no RoomEvent.Redaction hook into searchCache, and clearRoom/clearAll are the only delete paths). Once a message has been scanned into IndexedDB, deleting it in the room leaves its full decrypted plaintext on disk and searchable forever — cached hits are rendered from the synthetic row (rowToResultItem), which has no redaction metadata, so it shows as a normal result. Edits have the mirror problem: the * new text edit event is indexed as its own row, so both the pre-edit and the edit fallback text keep matching.

Second mechanism (related finding): the opt-in index writes decrypted body/formatted_body/poll text to IndexedDB, and there
is no invalidation path other than logout or the manual "Clear cached index" button. Nothing
listens for m.room.redaction, nothing reacts to leaving/forgetting a room, and clearRoom() is
exported but has zero callers (verified by grep). Worse, rowToResultItem fabricates a synthetic
event with unsigned: {}, so SearchResultGroup's event.unsigned?.redacted_because guard
(SearchResultGroup.tsx:125,145,175) can never fire for a cached row — a message that was "deleted
for everyone" is re-rendered verbatim from the local index. Rows only age out at 5000/room, so
plaintext for deleted messages and rooms the user has left persists indefinitely (and
requestPersistentStorage() makes it non-evictable).

How to trigger

Enable the persistent search index, search in an encrypted room (indexing the room), delete one of those messages, search for its text again.

Also: enable "Persist search index on this device", search an encrypted room so its messages are
indexed, then have the sender redact one of them (or leave the room). Search for that text again —
the redacted body is still returned and displayed.

Suggested fix

Subscribe to RoomEvent.Redaction (and m.replace relations) while the cache is enabled and delete/replace the affected [roomId, eventId] rows; at minimum filter cached rows against room.findEventById(row.eventId)?.isRedacted() at query time.

Also: subscribe to RoomEvent.Redaction (and Room.myMembership → leave/forget) and call a
new deleteRow(roomId, eventId) / the existing clearRoom(roomId); also carry the row's
redacted_because (or simply refuse to index redacted events) so the renderer's guard still applies.


Filed from the September 2026 client audit (branch lotus @ 4bea4895).

**Severity:** high · **Type:** security · **Confidence:** high **Location:** `src/app/features/message-search/useLocalMessageSearch.ts:183` (memory scan skips redacted), `:196-208` (persist), `src/app/utils/searchCache.ts:126-140` `src/app/utils/searchCache.ts:126-137` (`putRows`), `src/app/utils/searchCache.ts:291-302` (`clearRoom`, never called), `src/app/features/message-search/useLocalMessageSearch.ts:92-104,246-256`, `src/app/features/message-search/useLocalMessageSearch.ts:60-110` (`rowToResultItem`) ### Problem The in-memory scan correctly skips `event.isRedacted()`, but nothing ever removes an already-persisted row when its event is later redacted (there is no `RoomEvent.Redaction` hook into `searchCache`, and `clearRoom`/`clearAll` are the only delete paths). Once a message has been scanned into IndexedDB, deleting it in the room leaves its full decrypted plaintext on disk and searchable forever — cached hits are rendered from the synthetic row (`rowToResultItem`), which has no redaction metadata, so it shows as a normal result. Edits have the mirror problem: the `* new text` edit event is indexed as its own row, so both the pre-edit and the edit fallback text keep matching. **Second mechanism (related finding):** the opt-in index writes decrypted `body`/`formatted_body`/poll text to IndexedDB, and there is **no invalidation path other than logout or the manual "Clear cached index" button**. Nothing listens for `m.room.redaction`, nothing reacts to leaving/forgetting a room, and `clearRoom()` is exported but has zero callers (verified by grep). Worse, `rowToResultItem` fabricates a synthetic event with `unsigned: {}`, so `SearchResultGroup`'s `event.unsigned?.redacted_because` guard (`SearchResultGroup.tsx:125,145,175`) can never fire for a cached row — a message that was "deleted for everyone" is re-rendered verbatim from the local index. Rows only age out at 5000/room, so plaintext for deleted messages and rooms the user has left persists indefinitely (and `requestPersistentStorage()` makes it non-evictable). ### How to trigger Enable the persistent search index, search in an encrypted room (indexing the room), delete one of those messages, search for its text again. Also: enable "Persist search index on this device", search an encrypted room so its messages are indexed, then have the sender redact one of them (or leave the room). Search for that text again — the redacted body is still returned and displayed. ### Suggested fix Subscribe to `RoomEvent.Redaction` (and `m.replace` relations) while the cache is enabled and delete/replace the affected `[roomId, eventId]` rows; at minimum filter cached rows against `room.findEventById(row.eventId)?.isRedacted()` at query time. Also: subscribe to `RoomEvent.Redaction` (and `Room.myMembership` → leave/forget) and call a new `deleteRow(roomId, eventId)` / the existing `clearRoom(roomId)`; also carry the row's `redacted_because` (or simply refuse to index redacted events) so the renderer's guard still applies. --- _Filed from the September 2026 client audit (branch `lotus` @ 4bea4895)._
jared added this to the Audit 2026-09 · P0 security & data loss milestone 2026-09-12 01:50:50 -04:00
jared added the bugpriority: highsecurityarea: messaging labels 2026-09-12 01:50:50 -04:00
jared self-assigned this 2026-09-12 01:50:50 -04:00
jared closed this issue 2026-09-12 19:46:14 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
2026-09-25
Dependencies

No dependencies set.

Reference: LotusGuild/cinny#14