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
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:
@@ -6,7 +6,9 @@ import assert from 'node:assert/strict';
|
||||
// hoist above the mock).
|
||||
const removed: 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,
|
||||
setItem: (k: string, v: string) => {
|
||||
store.set(k, v);
|
||||
@@ -16,6 +18,13 @@ const store = new Map<string, string>();
|
||||
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'));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user