fix(security): composer drafts no longer survive logout or cross accounts
draft-msg-<roomId> was unscoped and deliberately skipped on logout, then hydrated into whoever logged in next. Wipe drafts in clearPlaintextCaches, and only hydrate a draft whose stored userId matches the current user. Drafts written before this change carry no userId and are dropped on first load (a one-time loss of unsent drafts, accepted for the leak fix). Fixes #41 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useStore } from 'jotai';
|
import { useStore } from 'jotai';
|
||||||
import { Descendant } from 'slate';
|
|
||||||
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
|
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
|
||||||
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
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
|
* 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.
|
* (identical content), so composer restore is unaffected.
|
||||||
*
|
*
|
||||||
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
|
* 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 {
|
export function useHydrateMsgDrafts(): void {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const userId = mx.getUserId();
|
||||||
let keys: string[];
|
let keys: string[];
|
||||||
try {
|
try {
|
||||||
keys = Object.keys(localStorage);
|
keys = Object.keys(localStorage);
|
||||||
@@ -34,7 +42,19 @@ export function useHydrateMsgDrafts(): void {
|
|||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(key);
|
const stored = localStorage.getItem(key);
|
||||||
if (!stored) return;
|
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)) {
|
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
|
||||||
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
|
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
|
||||||
}
|
}
|
||||||
@@ -42,5 +62,5 @@ export function useHydrateMsgDrafts(): void {
|
|||||||
// Ignore a malformed stored draft.
|
// Ignore a malformed stored draft.
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [store]);
|
}, [store, mx]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,31 @@ import { clearRecentForwardTargets } from './recentForwardTargets';
|
|||||||
import { clearRecentGifs } from './recentGifs';
|
import { clearRecentGifs } from './recentGifs';
|
||||||
import { clearRecentStickers } from './recentStickers';
|
import { clearRecentStickers } from './recentStickers';
|
||||||
import { clearNavToActivePathStore } from './navToActivePath';
|
import { clearNavToActivePathStore } from './navToActivePath';
|
||||||
|
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). 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
|
* 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_forward_targets_v1` — recent forward contact/room graph (PII)
|
||||||
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
||||||
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
* - `navToActivePath<userId>` — 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):
|
* NOT swept here (by design):
|
||||||
* - session credential keys → `removeFallbackSession()`
|
* - session credential keys → `removeFallbackSession()`
|
||||||
@@ -25,9 +54,8 @@ import { clearNavToActivePathStore } from './navToActivePath';
|
|||||||
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
|
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
|
||||||
* `mx.clearStores()` on both logout paths
|
* `mx.clearStores()` on both logout paths
|
||||||
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
|
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
|
||||||
* - unsent composer drafts (`draft-msg-*`) and the presence status message
|
* - the presence status message (`lotus-status-msg-*`) is deliberately
|
||||||
* (`lotus-status-msg-*`) are deliberately preserved across a normal logout
|
* preserved across a normal logout; clearing it is a separate product decision
|
||||||
* (N98); clearing them is a separate product decision
|
|
||||||
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
||||||
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
||||||
* preferences, not swept here
|
* preferences, not swept here
|
||||||
@@ -39,5 +67,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearRecentForwardTargets();
|
clearRecentForwardTargets();
|
||||||
clearRecentGifs();
|
clearRecentGifs();
|
||||||
clearRecentStickers();
|
clearRecentStickers();
|
||||||
|
clearMsgDrafts();
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user