Skip to content

Commit 7793c28

Browse files
committed
Address review feedback: scope-drift cleanup and small hardening
Addresses the consolidated review asks from PR #2802 rounds 1-3, all of which are local cleanups; no externally-observable behavior change beyond the two flagged hardening fixes (saveSettings validation drop and handleSignedHwPayload rearm narrowing). userActivity.ts handler: Rewrite the doc comment to match the shipped behavior. The previous text still described an unconditional surface-mount ping that was removed in 7b3e4dd for the security reason captured in that commit message (dApp-spawned signing popups would otherwise extend the alarm without user presence). saveSettings.ts handler: Drop the {error: 'Invalid autoLockTimeoutMinutes'} early-return. The response type has no error variant the popup could surface, and the only producer is a <Select> populated from VALID_AUTO_LOCK_TIMEOUT_MINUTES and coerced before dispatch. Coerce malformed input to the default instead, matching what the storage-read path already does. Tests updated accordingly. handleSignedHwPayload.ts handler: Move sessionTimer.resetSession() into the success branch. A malformed request (missing uuid, or no matching queue entry) is never a legitimate signal of user presence and must not extend the idle deadline. Tests flipped + a new no-matching-uuid case added. signOut.ts handler: Stop the auto-lock alarm BEFORE mutating session state, flushing, and removing the temporary store. Eliminates the race window where a pending alarm could fire clearSession on already-cleared state and emit a duplicate SESSION_LOCKED broadcast. Test asserts the full new ordering: stop < dispatch < flush < remove < broadcast. broadcast-session-state.ts: Replace bare 'catch {}' with a catch that warns on errors which are NOT the well-known 'no receivers' patterns. The common case (no UI surfaces open) stays silent; genuine broadcast failures are no longer invisible. SessionLockListener: Move 'location' into a useRef so the runtime.onMessage listener doesn't re-register on every navigation. Functionally identical (cleanup unregisters first), but avoids re-binding the listener on a hot path. loadSaveSettings.test.ts: Drop the entire unreferenced browser.alarms shim (alarmsGet / alarmsCreate / alarmsClear and the beforeEach that installs them on the polyfill). The current saveSettings handler operates entirely through the injected sessionTimer mock; the shim was scaffolding from the removed elapsed-idle short-circuit path. Also remove both '(result as any).wasLocked).toBeUndefined()' assertions, since the response type no longer admits the field, and rewrite the shortened-timeout test comment to describe the current save-as-activity behavior rather than the removed immediate-lock branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7b3e4dd commit 7793c28

9 files changed

Lines changed: 124 additions & 77 deletions

File tree

extension/src/background/messageListener/__tests__/handleSignedHwPayload.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ describe("handleSignedHwPayload", () => {
4242
expect(responseQueue).toEqual([]);
4343
});
4444

45-
it("still records user activity before returning an error for a missing uuid", async () => {
45+
it("does NOT extend the idle alarm when the uuid is missing", async () => {
4646
const sessionTimer = makeSessionTimer();
4747

4848
const result = await handleSignedHwPayload({
@@ -51,10 +51,25 @@ describe("handleSignedHwPayload", () => {
5151
sessionTimer,
5252
});
5353

54-
expect(sessionTimer.resetSession).toHaveBeenCalledTimes(1);
54+
// A malformed request is never a legitimate signal of user
55+
// presence — only the success branch rearms the idle timer.
56+
expect(sessionTimer.resetSession).not.toHaveBeenCalled();
5557
expect(result).toEqual({ error: "Transaction not found" });
5658
});
5759

60+
it("does NOT extend the idle alarm when no queue entry matches", async () => {
61+
const sessionTimer = makeSessionTimer();
62+
63+
const result = await handleSignedHwPayload({
64+
request: { uuid: "uuid-missing", signedPayload: "signed-xdr" } as any,
65+
responseQueue: [{ uuid: "uuid-other", response: jest.fn() }] as any,
66+
sessionTimer,
67+
});
68+
69+
expect(sessionTimer.resetSession).not.toHaveBeenCalled();
70+
expect(result).toEqual({ error: "Session timed out" });
71+
});
72+
5873
it("tracks hardware-wallet lock and unlock state transitions", () => {
5974
let state = { session: sessionSlice.reducer(undefined, { type: "init" }) };
6075
expect(isHardwareWalletLockedSelector(state)).toBe(false);

extension/src/background/messageListener/__tests__/loadSaveSettings.test.ts

Lines changed: 26 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,6 @@ import {
66
TEMPORARY_STORE_ID,
77
} from "constants/localStorageTypes";
88
import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock";
9-
import browser from "webextension-polyfill";
10-
11-
const alarmsGet = jest.fn();
12-
const alarmsCreate = jest.fn().mockResolvedValue(undefined);
13-
const alarmsClear = jest.fn().mockResolvedValue(undefined);
14-
15-
beforeEach(() => {
16-
alarmsGet.mockReset();
17-
alarmsCreate.mockClear();
18-
alarmsClear.mockClear();
19-
(browser as any).alarms = {
20-
get: alarmsGet,
21-
create: alarmsCreate,
22-
clear: alarmsClear,
23-
};
24-
});
259

2610
jest.mock("background/helpers/account", () => ({
2711
getAllowList: jest.fn().mockResolvedValue([]),
@@ -212,26 +196,38 @@ describe("saveSettings autoLockTimeoutMinutes", () => {
212196
isOpenSidebarByDefault: false,
213197
};
214198

215-
it("rejects invalid autoLockTimeoutMinutes values", async () => {
199+
it("coerces invalid autoLockTimeoutMinutes to the default rather than rejecting", async () => {
200+
// The Preferences `<Select>` only emits values from
201+
// `VALID_AUTO_LOCK_TIMEOUT_MINUTES`, but a malformed message (e.g.
202+
// from a future client revision) should still produce a sensible
203+
// stored value rather than silently dropping the whole save.
216204
const localStore = makeLocalStore();
217205
const result = await saveSettings({
218206
request: { ...baseRequest, autoLockTimeoutMinutes: 7 } as any,
219207
localStore,
220208
sessionStore: makeSessionStore(),
221209
sessionTimer: makeSessionTimer(),
222210
});
223-
expect((result as any).error).toBe("Invalid autoLockTimeoutMinutes");
224-
expect(localStore.setItem).not.toHaveBeenCalled();
211+
expect((result as any).error).toBeUndefined();
212+
expect(localStore.setItem).toHaveBeenCalledWith(
213+
AUTO_LOCK_TIMEOUT_MINUTES_ID,
214+
DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
215+
);
225216
});
226217

227-
it("rejects non-numeric autoLockTimeoutMinutes", async () => {
218+
it("coerces non-numeric autoLockTimeoutMinutes to the default", async () => {
219+
const localStore = makeLocalStore();
228220
const result = await saveSettings({
229221
request: { ...baseRequest, autoLockTimeoutMinutes: "15" } as any,
230-
localStore: makeLocalStore(),
222+
localStore,
231223
sessionStore: makeSessionStore(),
232224
sessionTimer: makeSessionTimer(),
233225
});
234-
expect((result as any).error).toBe("Invalid autoLockTimeoutMinutes");
226+
expect((result as any).error).toBeUndefined();
227+
expect(localStore.setItem).toHaveBeenCalledWith(
228+
AUTO_LOCK_TIMEOUT_MINUTES_ID,
229+
DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES,
230+
);
235231
});
236232

237233
it("persists a valid timeout and reschedules when unlocked", async () => {
@@ -251,7 +247,6 @@ describe("saveSettings autoLockTimeoutMinutes", () => {
251247
);
252248
expect(sessionTimer.resetSession).toHaveBeenCalledTimes(1);
253249
expect((result as any).autoLockTimeoutMinutes).toBe(30);
254-
expect((result as any).wasLocked).toBeUndefined();
255250
});
256251

257252
it("does not reschedule the timer when the wallet is locked", async () => {
@@ -266,27 +261,21 @@ describe("saveSettings autoLockTimeoutMinutes", () => {
266261
expect(sessionTimer.stopSession).not.toHaveBeenCalled();
267262
});
268263

269-
it("rearms (rather than locking) when the user shortens the timeout", async () => {
270-
// Saving settings is itself a user action, so shortening the
271-
// timeout should restart the idle clock with the new value rather
272-
// than synthesizing an immediate lock — even when the new threshold
273-
// is already smaller than the elapsed idle time of the in-flight
274-
// alarm. Set up `alarmsGet` to return an alarm whose remaining time
275-
// (1 min) is much less than the new 5 min timeout, i.e. elapsed
276-
// idle (59 min) ≫ new timeout (5 min). The previous implementation
277-
// would have detected this and locked immediately; the current
278-
// implementation must simply rearm.
279-
alarmsGet.mockResolvedValue({
280-
scheduledTime: Date.now() + 1 * 60_000,
281-
});
264+
it("treats a shortened timeout as user activity and rearms (does not lock immediately)", async () => {
265+
// Saving settings is itself a user action: the handler always
266+
// rearms the idle timer with the new timeout when the wallet is
267+
// unlocked. There is no immediate-lock branch even when the new
268+
// threshold is far below the elapsed idle time — the popup never
269+
// sees a `wasLocked` flag and the session store is never mutated
270+
// from this path.
282271
const localStore = makeLocalStore(60);
283272
const sessionTimer = makeSessionTimer();
284273
const sessionStore = {
285274
getState: () => ({ session: { hashKey: { key: "k" } } }),
286275
dispatch: jest.fn(),
287276
} as any;
288277

289-
const result = await saveSettings({
278+
await saveSettings({
290279
request: { ...baseRequest, autoLockTimeoutMinutes: 5 } as any,
291280
localStore,
292281
sessionStore,
@@ -296,7 +285,6 @@ describe("saveSettings autoLockTimeoutMinutes", () => {
296285
expect(sessionTimer.resetSession).toHaveBeenCalledTimes(1);
297286
expect(sessionTimer.stopSession).not.toHaveBeenCalled();
298287
expect(sessionStore.dispatch).not.toHaveBeenCalled();
299-
expect((result as any).wasLocked).toBeUndefined();
300288
});
301289
});
302290

extension/src/background/messageListener/__tests__/signOut.test.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ describe("signOut handler", () => {
1919
jest.clearAllMocks();
2020
});
2121

22-
it("flushes the session store before clearing temporary storage and broadcasting the lock", async () => {
22+
it("stops the alarm before mutating session state and broadcasts the lock", async () => {
2323
const localStore = {
2424
getItem: jest.fn().mockResolvedValue("MNEMONIC_PHRASE_CONFIRMED"),
2525
remove: jest.fn().mockResolvedValue(undefined),
@@ -36,16 +36,27 @@ describe("signOut handler", () => {
3636

3737
await signOut({ localStore, sessionStore, sessionTimer });
3838

39+
expect(sessionTimer.stopSession).toHaveBeenCalledTimes(1);
3940
expect(mockFlushSessionStore).toHaveBeenCalledWith(sessionStore);
4041
expect(localStore.remove).toHaveBeenCalledTimes(1);
4142
expect(mockBroadcastSessionState).toHaveBeenCalledWith(
4243
SERVICE_TYPES.SESSION_LOCKED,
4344
);
45+
46+
// stopSession must run before any state mutation so a pending
47+
// alarm can't fire `clearSession` (and emit a duplicate
48+
// SESSION_LOCKED broadcast) between the dispatch and the clear.
49+
expect(
50+
sessionTimer.stopSession.mock.invocationCallOrder[0],
51+
).toBeLessThan(sessionStore.dispatch.mock.invocationCallOrder[0]);
52+
expect(
53+
sessionStore.dispatch.mock.invocationCallOrder[0],
54+
).toBeLessThan(mockFlushSessionStore.mock.invocationCallOrder[0]);
4455
expect(
4556
mockFlushSessionStore.mock.invocationCallOrder[0],
4657
).toBeLessThan(localStore.remove.mock.invocationCallOrder[0]);
4758
expect(
48-
mockBroadcastSessionState.mock.invocationCallOrder[0],
49-
).toBeGreaterThan(mockFlushSessionStore.mock.invocationCallOrder[0]);
59+
localStore.remove.mock.invocationCallOrder[0],
60+
).toBeLessThan(mockBroadcastSessionState.mock.invocationCallOrder[0]);
5061
});
5162
});

extension/src/background/messageListener/handlers/handleSignedHwPayload.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,6 @@ export const handleSignedHwPayload = async ({
1818
}) => {
1919
const { signedPayload, uuid } = request;
2020

21-
// A user just completed a hardware-wallet signature — that is a real
22-
// user action, so extend the idle session. Without this the popup
23-
// ping is the only path that refreshes the alarm, and a slow HW
24-
// signing flow could outlast the timeout while the user is actively
25-
// working.
26-
await sessionTimer.resetSession();
27-
2821
if (!uuid) {
2922
captureException("handleSignedHwPayload: missing uuid in request");
3023
return { error: "Transaction not found" };
@@ -40,6 +33,12 @@ export const handleSignedHwPayload = async ({
4033
transactionResponse &&
4134
typeof transactionResponse.response === "function"
4235
) {
36+
// A user just completed a hardware-wallet signature — that is a
37+
// real user action, so extend the idle session. We only rearm on
38+
// the success branch; a malformed request or a missing queue
39+
// entry is never a legitimate signal of user presence and must
40+
// not be allowed to extend the deadline.
41+
await sessionTimer.resetSession();
4342
transactionResponse.response(signedPayload);
4443
return {};
4544
}

extension/src/background/messageListener/handlers/saveSettings.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { Store } from "redux";
22

33
import { SaveSettingsMessage } from "@shared/api/types/message-request";
4-
import {
5-
coerceAutoLockTimeoutMinutes,
6-
isValidAutoLockTimeoutMinutes,
7-
} from "@shared/constants/autoLock";
4+
import { coerceAutoLockTimeoutMinutes } from "@shared/constants/autoLock";
85
import {
96
getAllowList,
107
getFeatureFlags,
@@ -47,9 +44,16 @@ export const saveSettings = async ({
4744
autoLockTimeoutMinutes,
4845
} = request;
4946

50-
if (!isValidAutoLockTimeoutMinutes(autoLockTimeoutMinutes)) {
51-
return { error: "Invalid autoLockTimeoutMinutes" };
52-
}
47+
// `autoLockTimeoutMinutes` originates from the Preferences `<Select>`,
48+
// whose options are populated from `VALID_AUTO_LOCK_TIMEOUT_MINUTES`
49+
// and coerced through `coerceAutoLockTimeoutMinutes` before dispatch.
50+
// We coerce again here as defence-in-depth: a malformed value (e.g.
51+
// from a future client revision or a corrupted message) is clamped to
52+
// the default rather than rejected, since the response type has no
53+
// error variant the popup could surface to the user.
54+
const safeAutoLockTimeoutMinutes = coerceAutoLockTimeoutMinutes(
55+
autoLockTimeoutMinutes,
56+
);
5357

5458
await localStore.setItem(DATA_SHARING_ID, isDataSharingAllowed);
5559
await localStore.setItem(IS_VALIDATING_MEMO_ID, isMemoValidationEnabled);
@@ -60,7 +64,7 @@ export const saveSettings = async ({
6064
);
6165
await localStore.setItem(
6266
AUTO_LOCK_TIMEOUT_MINUTES_ID,
63-
autoLockTimeoutMinutes,
67+
safeAutoLockTimeoutMinutes,
6468
);
6569

6670
// Saving settings is itself a user action, so it counts as activity:

extension/src/background/messageListener/handlers/signOut.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,15 @@ export const signOut = async ({
2020
sessionStore: Store;
2121
sessionTimer: SessionTimer;
2222
}) => {
23+
// Cancel any pending auto-lock alarm FIRST — the wallet is being
24+
// locked explicitly, so the idle timer no longer needs to fire.
25+
// Clearing before mutating state eliminates the race window where
26+
// a pending alarm could fire `clearSession` on already-cleared
27+
// state and emit a duplicate SESSION_LOCKED broadcast.
28+
await sessionTimer.stopSession();
2329
sessionStore.dispatch(logOut());
2430
await flushSessionStore(sessionStore);
2531
await localStore.remove(TEMPORARY_STORE_ID);
26-
// Cancel any pending auto-lock alarm — the wallet is being locked
27-
// explicitly, so the idle timer no longer needs to fire.
28-
await sessionTimer.stopSession();
2932
await broadcastSessionState(SERVICE_TYPES.SESSION_LOCKED);
3033

3134
return {

extension/src/background/messageListener/handlers/userActivity.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,21 @@ import {
1212
/**
1313
* Handle a USER_ACTIVITY ping from an extension page.
1414
*
15-
* Pings come from two sources inside `useActivityPing`:
16-
* 1. An unconditional mount ping fired by every Freighter surface
17-
* (popup, sidebar, fullscreen) as it loads.
18-
* 2. Throttled user-input events while the popup believes the wallet
19-
* is unlocked.
15+
* Pings originate from `useActivityPing`, which fires (throttled to
16+
* one per 5 s) on direct user input — `mousedown`, `keydown`,
17+
* `touchstart`, `wheel` — inside any Freighter surface (popup,
18+
* sidebar, standalone signing window, grant-access window). Surface
19+
* mounts deliberately do NOT ping: a dApp-spawned signing popup is
20+
* programmatic, not proof of user presence, and a mount-ping would
21+
* let a malicious dApp keep the session alive indefinitely.
2022
*
21-
* Either way, the popup-side `isUnlocked` is a delayed reflection of
23+
* The popup-side `isUnlocked` it gates on is a delayed reflection of
2224
* the background's session state (it depends on `loadAccount` having
2325
* dispatched `saveAccount` into the popup's redux store). The
2426
* background is the source of truth, so this handler authoritatively
25-
* checks whether the wallet is currently unlocked before rearming the
26-
* idle alarm. A ping that arrives on a locked wallet is dropped: there
27-
* is no live session to extend.
27+
* re-checks whether the wallet is currently unlocked before rearming
28+
* the idle alarm. A ping that arrives on a locked wallet is dropped:
29+
* there is no live session to extend.
2830
*
2931
* Unlocked = either a hot-wallet session (`hashKey` is set) or an
3032
* active hardware-wallet session that has not been idle-locked.

extension/src/background/messageListener/helpers/broadcast-session-state.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@ import browser from "webextension-polyfill";
22

33
import { SERVICE_TYPES } from "@shared/constants/services";
44

5+
// Chrome's runtime.sendMessage rejects with this message whenever no
6+
// extension contexts are listening for the broadcast. That is the
7+
// common case (e.g. the user has every Freighter surface closed when
8+
// the idle alarm fires) and is harmless. Anything else is unexpected
9+
// and worth logging so it doesn't get silently swallowed.
10+
const NO_RECEIVER_PATTERNS = [
11+
"Could not establish connection",
12+
"Receiving end does not exist",
13+
];
14+
515
export const broadcastSessionState = async (
616
type: SERVICE_TYPES.SESSION_LOCKED | SERVICE_TYPES.SESSION_UNLOCKED,
717
): Promise<void> => {
818
try {
919
await browser.runtime.sendMessage({ type });
10-
} catch {
11-
// No receivers — harmless.
20+
} catch (e) {
21+
const message = e instanceof Error ? e.message : String(e);
22+
if (!NO_RECEIVER_PATTERNS.some((p) => message.includes(p))) {
23+
console.warn(`broadcastSessionState(${type}) failed:`, e);
24+
}
1225
}
1326
};

extension/src/popup/components/SessionLockListener/index.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from "react";
1+
import { useEffect, useRef } from "react";
22
import { useDispatch } from "react-redux";
33
import { useLocation, useNavigate } from "react-router-dom";
44
import browser from "webextension-polyfill";
@@ -32,6 +32,16 @@ export const SessionLockListener = () => {
3232
const navigate = useNavigate();
3333
const location = useLocation();
3434

35+
// Mirror `location` into a ref so the `runtime.onMessage` listener
36+
// can read the latest value without being a dependency of the
37+
// effect that registers it. Without this the listener would be
38+
// re-registered on every navigation — functionally fine (the
39+
// cleanup unregisters first), but unnecessary churn on a hot path.
40+
const locationRef = useRef(location);
41+
useEffect(() => {
42+
locationRef.current = location;
43+
}, [location]);
44+
3545
useEffect(() => {
3646
// IMPORTANT: this handler must be a *synchronous* function. Every
3747
// Freighter UI surface (popup, sidebar, fullscreen) registers a
@@ -61,13 +71,15 @@ export const SessionLockListener = () => {
6171
return undefined;
6272
}
6373

74+
const currentLocation = locationRef.current;
75+
6476
if (type === SERVICE_TYPES.SESSION_LOCKED) {
6577
// Already on the unlock screen — nothing to do. Avoids clobbering
6678
// an existing `state.from` set by an earlier reroute.
67-
if (location.pathname === ROUTES.unlockAccount) return undefined;
79+
if (currentLocation.pathname === ROUTES.unlockAccount) return undefined;
6880
dispatch(lockAccount());
69-
navigate(`${ROUTES.unlockAccount}${location.search}`, {
70-
state: { from: location },
81+
navigate(`${ROUTES.unlockAccount}${currentLocation.search}`, {
82+
state: { from: currentLocation },
7183
});
7284
return undefined;
7385
}
@@ -94,7 +106,7 @@ export const SessionLockListener = () => {
94106
return () => {
95107
browser.runtime.onMessage.removeListener(handler);
96108
};
97-
}, [dispatch, navigate, location]);
109+
}, [dispatch, navigate]);
98110

99111
return null;
100112
};

0 commit comments

Comments
 (0)