privacy: wipe the local status-message mirror on logout (#204)
CI / Build & Quality Checks (push) Successful in 1m35s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Successful in 3m23s

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-19 13:38:47 -04:00
co-authored by Claude Opus 5
parent c921f11521
commit e078a2cc10
2 changed files with 44 additions and 6 deletions
+17 -4
View File
@@ -6,7 +6,9 @@ import assert from 'node:assert/strict';
// hoist above the mock). // hoist above the mock).
const removed: string[] = []; const removed: string[] = [];
const store = new Map<string, string>(); const store = new Map<string, string>();
(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, getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => { setItem: (k: string, v: string) => {
store.set(k, v); store.set(k, v);
@@ -16,6 +18,13 @@ const store = new Map<string, string>();
store.delete(k); 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'); 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'); 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.clear();
store.set('draft-msg-!room:server', '{"body":"unsent"}'); 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"}'); store.set('cinny_session', '{"accessToken":"x"}');
removed.length = 0; removed.length = 0;
clearPlaintextCaches('@me:server'); 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 modules concern'); assert.ok(!removed.includes('cinny_session'), 'session key not this modules concern');
assert.ok(store.has('draft-msg-!room:server')); assert.ok(store.has('cinny_session'));
}); });
+27 -2
View File
@@ -54,12 +54,36 @@ const clearMsgDrafts = (): void => {
* 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()`
* - the presence status message (`lotus-status-msg-*`) is deliberately * - (the presence status message + expiry, `lotus-status-msg-*` /
* preserved across a normal logout; clearing it is a separate product decision * `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 * - 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
*/ */
/**
* [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 => { export const clearPlaintextCaches = (userId?: string): void => {
clearTranslationCache(); clearTranslationCache();
clearScheduledMessages(); clearScheduledMessages();
@@ -68,5 +92,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearRecentGifs(); clearRecentGifs();
clearRecentStickers(); clearRecentStickers();
clearMsgDrafts(); clearMsgDrafts();
clearStatusMessage();
if (userId) clearNavToActivePathStore(userId); if (userId) clearNavToActivePathStore(userId);
}; };