CI / Build & Quality Checks (pull_request) Successful in 1m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m51s
#266: remembered widget approvals (lotus.widgetConsent.*) survived logout, so they applied silently to the next account on the device and listed the previous account's rooms; clearPlaintextCaches now sweeps them. Space structure, call membership and Lotus room-policy state join PROTECTED_STATE_TYPES, so no widget can be granted them. #267: any website could auto-submit a form to /share-target and land the user in the share picker with attacker-chosen text and files. The worker now refuses a share whose Origin is another site. (Sec-Fetch-Site, as the issue suggested, never reaches a service worker: checked in Chromium.) Origin null/absent, as the OS share sheet sends, stays accepted, and /share now says the content came from another app or website. Both logout paths await clearSharedPayload(); the server-forced one also clears the soundboard clip cache, as #57 intended. Fixes #266 Fixes #267 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
|
||
// These modules touch localStorage at import/runtime. Provide a minimal mock
|
||
// that records removed keys, then import dynamically (a static import would
|
||
// hoist above the mock).
|
||
const removed: string[] = [];
|
||
const store = new Map<string, string>();
|
||
// 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);
|
||
},
|
||
removeItem: (k: string) => {
|
||
removed.push(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, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
|
||
await import('./plaintextCaches');
|
||
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
|
||
await import('../features/room/widgets/widgetUtils');
|
||
|
||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||
store.clear();
|
||
store.set('cinny_translation_cache_v1', '[]');
|
||
store.set('cinny_scheduled_messages_v1', '{}');
|
||
store.set('cinny_recent_searches_v1', '[]');
|
||
store.set('cinny_recent_forward_targets_v1', '[]');
|
||
store.set('cinny_recent_gifs_v1', '[]');
|
||
store.set('cinny_recent_stickers_v1', '[]');
|
||
store.set('lotus_outbox_v1', '{"userId":"@me:server","entries":[]}');
|
||
removed.length = 0;
|
||
|
||
clearPlaintextCaches();
|
||
|
||
for (const key of [
|
||
'cinny_translation_cache_v1',
|
||
'cinny_scheduled_messages_v1',
|
||
'cinny_recent_searches_v1',
|
||
'cinny_recent_forward_targets_v1',
|
||
'cinny_recent_gifs_v1',
|
||
'cinny_recent_stickers_v1',
|
||
'lotus_outbox_v1', // [Gitea #112] unsent message content
|
||
]) {
|
||
assert.ok(removed.includes(key), `${key} cleared`);
|
||
}
|
||
assert.equal(store.size, 0, 'all keys gone from store');
|
||
});
|
||
|
||
test('clearPlaintextCaches clears the per-user nav-path store only when given a userId', () => {
|
||
store.clear();
|
||
store.set('navToActivePath@me:server', '{}');
|
||
removed.length = 0;
|
||
|
||
clearPlaintextCaches(); // no userId -> nav path untouched
|
||
assert.ok(!removed.includes('navToActivePath@me:server'), 'nav path kept without userId');
|
||
|
||
clearPlaintextCaches('@me:server');
|
||
assert.ok(removed.includes('navToActivePath@me:server'), 'nav path cleared with userId');
|
||
});
|
||
|
||
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 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('cinny_session'));
|
||
});
|
||
|
||
test('clearPlaintextCaches removes the call-volume list and the settings-sync stamp (#347)', () => {
|
||
store.clear();
|
||
store.set('lotus-per-user-volume', '{"@bob:server":0.3}');
|
||
store.set('settings-sync-meta:@me:server', '{"lastSyncedAt":1}');
|
||
store.set('settings', '{}');
|
||
clearPlaintextCaches('@me:server');
|
||
assert.equal(store.has('lotus-per-user-volume'), false);
|
||
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||
});
|
||
|
||
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
|
||
store.clear();
|
||
store.set('searchCacheEnabled', 'true');
|
||
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
|
||
clearPlaintextCaches('@me:server');
|
||
assert.equal(store.has('searchCacheEnabled'), false);
|
||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||
});
|
||
|
||
test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => {
|
||
assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync');
|
||
store.clear();
|
||
const consent = widgetConsentKey('!room:server', 'widget-1');
|
||
store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}');
|
||
store.set('lotus.widgetConsentNotAKey', 'kept');
|
||
removed.length = 0;
|
||
|
||
clearPlaintextCaches('@me:server');
|
||
|
||
assert.ok(removed.includes(consent), 'consent cleared');
|
||
assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept');
|
||
});
|