Skip to content

Commit 2e269b0

Browse files
aaspinwallAlejandro Aspinwall
andauthored
fix(addon): fully wipe add-on storage on logout (#1054, #1023) (#1048)
* fix(addon): scope menuLogout() storage clear to STORAGE_KEY_AUTH only menuLogout() previously called browser.storage.local.clear(), wiping unrelated in-flight data in the same namespace -- specifically PENDING_ADDON_TOKEN (an in-progress AccountHub login) and SEND_MESSAGE_TO_BRIDGE (a passphrase staged for the bridge handoff). Replace the blanket clear with a scoped remove() targeting only the auth session, leaving the in-flight data intact. Refs #1023 Closes #1023 * test(addon): update logout tests for scoped storage remove (fix CI) menuLogout() now scoped-removes STORAGE_KEY_AUTH instead of a blanket storage.local.clear() (this PR's fix), but two pre-existing tests still asserted clear() was called and failed CI (addon-changes / Shared package tests and lint): - menu.test.ts 'opens the logout page and clears storage after closing tabs' - happy-clean-logout-no-inflight-work.test.ts 'closes only Send tabs...' Both now assert storage.local.remove(STORAGE_KEY_AUTH) and that clear() is not called. Added the STORAGE_KEY_AUTH import to the integration spec. * fix(addon): fully wipe add-on storage on logout (#1054) Revert the scoped single-key remove() approach and restore a full browser.storage.local.clear() on genuine logout, returning the add-on to a clean, logged-out state (auth token, staged passphrase, pending OIDC token set, folder-lock records, cloud-file configs all cleared). storage.local is per-extension isolated (keyed to the add-on's gecko id), NOT shared with Thunderbird core or other add-ons, so a blanket clear() only ever touches TB-Send's own data. A scoped remove() is fail-open: it leaks the passphrase and a live refresh token past logout. A concurrent in-flight login (PENDING_ADDON_TOKEN) is intentionally cancelled by logout. The blanket clear() is only reachable from a genuine logout (menuLogout via LOGOUT action / SIGN_OUT); the read-only getLoginState() probe never wipes (see #948/#949). Tests updated to assert the full-wipe behavior. Closes #1054 --------- Co-authored-by: aaspinwall <aaspinwall@users.noreply.github.com> Co-authored-by: Alejandro Aspinwall <aaspinwall@thunderbird.net>
1 parent 2c6c646 commit 2e269b0

4 files changed

Lines changed: 72 additions & 44 deletions

File tree

packages/addon/src/menu.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,25 @@ export async function menuLogout() {
115115
console.log('🧹 Clearing menu items and storage');
116116
await browser.TBProMenu.clear('root');
117117

118-
// Clear all extension storage
118+
// Full wipe of the add-on's storage to a clean, logged-out state.
119+
//
120+
// browser.storage.local is namespaced PER-EXTENSION (keyed to this add-on's
121+
// gecko id) -- it is NOT shared with Thunderbird core or any other add-on.
122+
// So every key in here is TB-Send's own: STORAGE_KEY_AUTH, the staged
123+
// passphrase (SEND_MESSAGE_TO_BRIDGE), the pending OIDC token set
124+
// (PENDING_ADDON_TOKEN), folder-lock records, cloud-file account configs, etc.
125+
//
126+
// On a genuine logout the product requirement is a clean wipe: auth token,
127+
// passphrase, and ALL other add-on data must be gone so the next launch
128+
// requires a fresh login. A scoped single-key remove() leaks the passphrase
129+
// and a live refresh token past logout (see #1023 / #1054). A concurrent
130+
// in-flight login (PENDING_ADDON_TOKEN) is intentionally cancelled here --
131+
// logout wins over a half-finished login by design.
132+
//
133+
// This blanket clear() is ONLY reached from a genuine logout (menuLogout(),
134+
// driven by the LOGOUT menu action / the SIGN_OUT message). The read-only
135+
// getLoginState() probe (60s timer + route guard) MUST NOT reach this and
136+
// does not -- it only ever calls storage.local.get(). See #948/#949.
119137
await browser.storage.local.clear();
120138

121139
// Clear localStorage (if running in a context that has access to it)
Lines changed: 44 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,34 @@
11
/**
2-
* A5 — `menuLogout()`'s blanket `storage.local.clear()` collateral damage.
2+
* A5 — `menuLogout()` must fully wipe the add-on's storage on logout.
33
*
4-
* See ADDON-BUG-REPORTS-2026-07-22.md #A5 and
5-
* ADDON-SYNC-VERIFIED-FINDINGS-2026-07-21.md §A5.
4+
* See ADDON-BUG-REPORTS-2026-07-22.md #A5,
5+
* ADDON-SYNC-VERIFIED-FINDINGS-2026-07-21.md §A5, and #1054.
6+
*
7+
* Product requirement: a genuine logout (from the menu OR the web app inside
8+
* Thunderbird) returns the add-on to a clean, logged-out state -- the auth
9+
* token, the staged passphrase, and ALL other add-on data are gone, so the
10+
* next launch requires a fresh login.
11+
*
12+
* browser.storage.local is namespaced PER-EXTENSION (keyed to this add-on's
13+
* gecko id); it is NOT shared with Thunderbird core or any other add-on. Every
14+
* key in it is TB-Send's own, so a blanket clear() only ever touches TB-Send
15+
* data -- which is exactly what "clean wipe" wants. A scoped single-key
16+
* remove() would leak the passphrase (SEND_MESSAGE_TO_BRIDGE) and a live
17+
* refresh token (PENDING_ADDON_TOKEN) past logout.
18+
*
19+
* A concurrent in-flight login (PENDING_ADDON_TOKEN) is intentionally
20+
* cancelled by logout -- logout wins over a half-finished login by design.
621
*
722
* Mechanism under test (menu.ts, menuLogout()):
823
* await browser.storage.local.clear();
924
*
10-
* This unconditional, blanket clear lives in the same browser.storage.local
11-
* namespace as unrelated in-flight staged data:
12-
* - PENDING_ADDON_TOKEN (background.ts's triggerAddonLogin() staging key
13-
* for an AccountHub-driven login in progress)
14-
* - SEND_MESSAGE_TO_BRIDGE (a passphrase staged for the bridge handoff)
15-
*
16-
* This test drives the real `menuLogout()` against the fake host's shared
17-
* storage and proves that BOTH of those unrelated keys are destroyed by a
18-
* logout that has nothing to do with them, with no error surfaced to
19-
* whichever flow was relying on them.
25+
* This spec asserts that a logout leaves NOTHING behind in storage.
2026
*/
2127
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2228
import { type FakeHost } from './fakeThunderbirdHost';
2329
import { setupHost, stubContext, teardownHost } from './testHelpers';
2430

25-
describe('A5: menuLogout() blanket storage.local.clear() wipes unrelated in-flight data', () => {
31+
describe('A5: menuLogout() must fully wipe add-on storage to a clean logged-out state', () => {
2632
let host: FakeHost;
2733

2834
beforeEach(() => {
@@ -33,56 +39,54 @@ describe('A5: menuLogout() blanket storage.local.clear() wipes unrelated in-flig
3339
teardownHost();
3440
});
3541

36-
it('CONFIRMED BUG: a concurrent AccountHub login and bridged passphrase are silently destroyed by an unrelated logout', async () => {
42+
it('FULL WIPE: auth token, staged passphrase, pending login token, and account config are ALL cleared on logout', async () => {
3743
const ctx = stubContext(host);
3844
const { menuLogout } = await import('../../menu');
3945

40-
// Simulate two unrelated flows mid-flight, both staging data in the
41-
// same browser.storage.local namespace menuLogout() will nuke:
42-
// 1. An AccountHub-driven login has staged a pending token set.
43-
// 2. A passphrase bridge handoff is staged, waiting to be consumed by
44-
// restoreKeysUsingLocalStorage() in another context (popup/web).
46+
// Seed the storage with a representative spread of the add-on's own keys,
47+
// matching what background.ts / auth-store.ts / extension-store.ts write:
48+
// - the auth session
49+
// - a staged passphrase (bridge handoff) -- security-sensitive
50+
// - a pending OIDC token set (in-flight add-on login) -- a refresh token
51+
// - a per-account cloud-file server config
4552
await ctx.browser.storage.local.set({
46-
'tbpro-pending-addon-token': {
47-
refresh_token: 'staged-refresh-token',
48-
},
53+
'send-auth': { some: 'auth-session' },
4954
SEND_MESSAGE_TO_BRIDGE: 'staged-passphrase-words',
50-
// Also seed an unrelated per-account cloud-file server config key,
51-
// matching extension-store.ts's browser.storage.local.set({[id]: ...}).
55+
'tbpro-pending-addon-token': { refresh_token: 'staged-refresh-token' },
5256
'account-123': { server: 'https://send.tb.pro' },
5357
});
5458

55-
// Sanity: all three unrelated keys are present before logout.
59+
// Sanity: everything is present before logout.
5660
const before = await ctx.browser.storage.local.get([
57-
'tbpro-pending-addon-token',
61+
'send-auth',
5862
'SEND_MESSAGE_TO_BRIDGE',
63+
'tbpro-pending-addon-token',
5964
'account-123',
6065
]);
61-
expect(before['tbpro-pending-addon-token']).toBeDefined();
66+
expect(before['send-auth']).toBeDefined();
6267
expect(before['SEND_MESSAGE_TO_BRIDGE']).toBeDefined();
68+
expect(before['tbpro-pending-addon-token']).toBeDefined();
6369
expect(before['account-123']).toBeDefined();
6470

65-
// Now an unrelated logout happens (e.g. the user clicked Logout in the
66-
// hamburger menu, or a SIGN_OUT message arrived) while the above flows
67-
// are still mid-flight.
71+
// A genuine logout (LOGOUT menu action / SIGN_OUT message).
6872
await menuLogout();
6973

70-
// THE BUG: menuLogout() calls browser.storage.local.clear() -- a
71-
// blanket wipe, not scoped to STORAGE_KEY_AUTH -- so all three unrelated
72-
// keys are silently destroyed with no error surfaced to either the
73-
// AccountHub login flow or the passphrase bridge handoff.
74+
// FULL WIPE: nothing the add-on owns survives the logout. The passphrase
75+
// and the refresh token in particular must NOT leak past sign-out.
7476
const after = await ctx.browser.storage.local.get([
75-
'tbpro-pending-addon-token',
77+
'send-auth',
7678
'SEND_MESSAGE_TO_BRIDGE',
79+
'tbpro-pending-addon-token',
7780
'account-123',
7881
]);
79-
expect(after['tbpro-pending-addon-token']).toBeUndefined();
82+
expect(after['send-auth']).toBeUndefined();
8083
expect(after['SEND_MESSAGE_TO_BRIDGE']).toBeUndefined();
84+
expect(after['tbpro-pending-addon-token']).toBeUndefined();
8185
expect(after['account-123']).toBeUndefined();
8286

83-
// Confirm this was via the blanket clear() call specifically (not a
84-
// scoped set of individual remove() calls for an allow-list), which is
85-
// exactly the fix target identified in the bug report.
87+
// Confirm the wipe was via the blanket clear() -- the correct mechanism
88+
// for a "return to clean state" logout, since storage.local is
89+
// per-extension isolated (see #1054).
8690
expect(ctx.browser.storage.local.clear).toHaveBeenCalledTimes(1);
8791
});
8892
});

packages/addon/src/test/integration/happy-clean-logout-no-inflight-work.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@ describe('Happy path: clean logout with no in-flight work', () => {
3636
expect(ctx.browser.tabs.remove).toHaveBeenCalledTimes(1);
3737
expect(ctx.browser.tabs.remove).toHaveBeenCalledWith(1);
3838

39-
// Storage was cleared (there was nothing unrelated in it to lose, so
40-
// this is the intended, harmless case).
39+
// Storage was fully wiped via the blanket clear() -- a genuine logout
40+
// returns the add-on to a clean, logged-out state. storage.local is
41+
// per-extension isolated, so this only touches TB-Send's own data.
42+
// See #1054 / A5.
4143
expect(ctx.browser.storage.local.clear).toHaveBeenCalledTimes(1);
4244

4345
// Menu was reset to the logged-out state.

packages/addon/src/test/menu.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@ describe('menuLogout', () => {
146146

147147
await menuLogout();
148148

149-
expect(browser.storage.local.clear).toHaveBeenCalled();
149+
// menuLogout() fully wipes the add-on's storage via a blanket
150+
// storage.local.clear() -- a genuine logout returns the add-on to a clean,
151+
// logged-out state. storage.local is per-extension isolated, so this only
152+
// touches TB-Send's own data (see #1054 / A5).
153+
expect(browser.storage.local.clear).toHaveBeenCalledTimes(1);
150154
expect(browser.tabs.create).toHaveBeenCalledWith({
151155
url: `${BASE_URL}/logout`,
152156
});

0 commit comments

Comments
 (0)