diff --git a/src/app/hooks/useHydrateMsgDrafts.ts b/src/app/hooks/useHydrateMsgDrafts.ts index a67b6cb65..2f97f5b39 100644 --- a/src/app/hooks/useHydrateMsgDrafts.ts +++ b/src/app/hooks/useHydrateMsgDrafts.ts @@ -1,8 +1,8 @@ import { useEffect } from 'react'; import { useStore } from 'jotai'; -import { Descendant } from 'slate'; import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts'; import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft'; +import { useMatrixClient } from './useMatrixClient'; /** * On startup, pre-fill the per-room message-draft atoms from their localStorage @@ -13,11 +13,19 @@ import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft'; * (identical content), so composer restore is unaffected. * * Thread drafts (key contains `::`) are skipped — the nav indicator is room-level. + * + * [Gitea #41] Drafts are stored as `{ userId, nodes }` (RoomInput's persist + * path) so a draft written by a different account never gets hydrated into the + * currently logged-in user's session. A legacy draft (stored as a bare array, + * pre-dating user-scoping) has no userId to check, so it's treated as foreign + * and dropped rather than trusted. */ export function useHydrateMsgDrafts(): void { const store = useStore(); + const mx = useMatrixClient(); useEffect(() => { + const userId = mx.getUserId(); let keys: string[]; try { keys = Object.keys(localStorage); @@ -34,7 +42,19 @@ export function useHydrateMsgDrafts(): void { try { const stored = localStorage.getItem(key); if (!stored) return; - const nodes = JSON.parse(stored) as Descendant[]; + const parsed = JSON.parse(stored); + const foreign = + !parsed || + typeof parsed !== 'object' || + Array.isArray(parsed) || + parsed.userId !== userId; + if (foreign) { + // Another account's (or a pre-scoping legacy) draft — never hydrate it, + // and drop it so it can't resurface for the next login either. + localStorage.removeItem(key); + return; + } + const nodes = parsed.nodes; if (Array.isArray(nodes) && hasMsgDraft(nodes)) { store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes); } @@ -42,5 +62,5 @@ export function useHydrateMsgDrafts(): void { // Ignore a malformed stored draft. } }); - }, [store]); + }, [store, mx]); } diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index f771936c0..ef3893911 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -5,6 +5,31 @@ import { clearRecentForwardTargets } from './recentForwardTargets'; import { clearRecentGifs } from './recentGifs'; import { clearRecentStickers } from './recentStickers'; import { clearNavToActivePathStore } from './navToActivePath'; +import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft'; + +/** + * [Gitea #41] Wipe every persisted composer draft (`draft-msg-`). Drafts + * hold decrypted, unsent message text with no user scoping, so leaving them in + * place across logout lets the next account on this device see (and send) the + * previous user's draft the moment they open the same room. + */ +const clearMsgDrafts = (): void => { + let keys: string[]; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + keys.forEach((key) => { + if (key.startsWith(DRAFT_MSG_KEY_PREFIX)) { + try { + localStorage.removeItem(key); + } catch { + // Best-effort — a single unreadable/blocked key must not abort the sweep. + } + } + }); +}; /** * Single auditable place that wipes the `localStorage` caches holding decrypted @@ -18,6 +43,10 @@ import { clearNavToActivePathStore } from './navToActivePath'; * - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII) * - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent * - `navToActivePath` — per-space last-visited room paths (needs userId) + * - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by + * user — see [Gitea #41]; previously deliberately preserved across logout + * (N98), which let the next account on this device see/send a prior user's + * draft, so this is no longer a "by design" exemption) * * NOT swept here (by design): * - session credential keys → `removeFallbackSession()` @@ -25,9 +54,8 @@ import { clearNavToActivePathStore } from './navToActivePath'; * bookmarks, user notes, status presets — themselves plaintext) → wiped by * `mx.clearStores()` on both logout paths * - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()` - * - unsent composer drafts (`draft-msg-*`) and the presence status message - * (`lotus-status-msg-*`) are deliberately preserved across a normal logout - * (N98); clearing them is a separate product decision + * - the presence status message (`lotus-status-msg-*`) is deliberately + * preserved across a normal logout; clearing it is a separate product decision * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as * preferences, not swept here @@ -39,5 +67,6 @@ export const clearPlaintextCaches = (userId?: string): void => { clearRecentForwardTargets(); clearRecentGifs(); clearRecentStickers(); + clearMsgDrafts(); if (userId) clearNavToActivePathStore(userId); };