From e078a2cc105615fa44fc2a73f7b041bd6f1f4da2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sat, 19 Sep 2026 13:38:47 -0400 Subject: [PATCH] privacy: wipe the local status-message mirror on logout (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Of the two plaintext-localStorage items in #204, composer drafts were already swept on logout (#41); the presence status message + expiry were deliberately kept. They are PII with an authoritative copy in server presence, so sweep them too. The test's localStorage mock now enumerates keys like the real Storage object, so the prefix sweeps (drafts, status) are actually exercised — the old 'draft preserved' assertion only passed because Object.keys() saw nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/state/plaintextCaches.test.ts | 21 +++++++++++++++---- src/app/state/plaintextCaches.ts | 29 +++++++++++++++++++++++++-- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/app/state/plaintextCaches.test.ts b/src/app/state/plaintextCaches.test.ts index a4c688e1f..aa0630771 100644 --- a/src/app/state/plaintextCaches.test.ts +++ b/src/app/state/plaintextCaches.test.ts @@ -6,7 +6,9 @@ import assert from 'node:assert/strict'; // hoist above the mock). const removed: string[] = []; const store = new Map(); -(globalThis as { localStorage?: unknown }).localStorage = { +// A Proxy so `Object.keys(localStorage)` (used by the prefix sweeps) sees the +// stored keys, like the real Storage object. +const api = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => { store.set(k, v); @@ -16,6 +18,13 @@ const store = new Map(); store.delete(k); }, }; +(globalThis as { localStorage?: unknown }).localStorage = new Proxy(api, { + ownKeys: () => Array.from(store.keys()), + getOwnPropertyDescriptor: (target, key) => + typeof key === 'string' && store.has(key) + ? { value: store.get(key), enumerable: true, configurable: true, writable: true } + : Object.getOwnPropertyDescriptor(target, key), +}); const { clearPlaintextCaches } = await import('./plaintextCaches'); @@ -56,15 +65,19 @@ test('clearPlaintextCaches clears the per-user nav-path store only when given a assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId'); }); -test('clearPlaintextCaches does NOT touch drafts or session keys', () => { +test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but not session keys', () => { store.clear(); store.set('draft-msg-!room:server', '{"body":"unsent"}'); + store.set('lotus-status-msg-@me:server', 'at the dentist'); + store.set('lotus-status-expiry-@me:server', '123'); store.set('cinny_session', '{"accessToken":"x"}'); removed.length = 0; clearPlaintextCaches('@me:server'); - assert.ok(!removed.includes('draft-msg-!room:server'), 'draft preserved (N98)'); + assert.ok(removed.includes('draft-msg-!room:server'), 'draft cleared'); + assert.ok(removed.includes('lotus-status-msg-@me:server'), 'status message cleared'); + assert.ok(removed.includes('lotus-status-expiry-@me:server'), 'status expiry cleared'); assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern'); - assert.ok(store.has('draft-msg-!room:server')); + assert.ok(store.has('cinny_session')); }); diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index ef3893911..45b6e04f3 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -54,12 +54,36 @@ const clearMsgDrafts = (): void => { * bookmarks, user notes, status presets — themselves plaintext) → wiped by * `mx.clearStores()` on both logout paths * - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()` - * - the presence status message (`lotus-status-msg-*`) is deliberately - * preserved across a normal logout; clearing it is a separate product decision + * - (the presence status message + expiry, `lotus-status-msg-*` / + * `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are + * swept with the rest — the server-side presence status survives, so a + * re-login loses nothing) * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as * preferences, not swept here */ +/** + * [Gitea #204] The local mirror of the user's status message (+ its expiry) is + * PII in plaintext; the authoritative copy lives in server presence. + */ +const clearStatusMessage = (): void => { + let keys: string[]; + try { + keys = Object.keys(localStorage); + } catch { + return; + } + keys.forEach((key) => { + if (key.startsWith('lotus-status-msg-') || key.startsWith('lotus-status-expiry-')) { + try { + localStorage.removeItem(key); + } catch { + // best-effort + } + } + }); +}; + export const clearPlaintextCaches = (userId?: string): void => { clearTranslationCache(); clearScheduledMessages(); @@ -68,5 +92,6 @@ export const clearPlaintextCaches = (userId?: string): void => { clearRecentGifs(); clearRecentStickers(); clearMsgDrafts(); + clearStatusMessage(); if (userId) clearNavToActivePathStore(userId); };