Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 59 additions & 36 deletions src/background/handlers/cancel-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,41 +196,21 @@ export async function cancelRequestsDisplacedBy(
await cancelRequests(store, candidates, source, windowId, afterMark);
}

// The trigger with no window event behind it — either `windows.create`
// rejected (no `windows.onRemoved` will ever fire for a window that never
// existed) or the startup sweep decided a hydrated 'open' row is orphaned
// (spec §8.1). Without this the dapp promise hangs until its own timeout
// (30 min by default).
//
// `source` defaults to the original trigger so the `open-window-failed` call
// site keeps its BANNER policy unchanged — its delivery does not: #1484's
// stale-tab check below applies to every source alike, so that call site's
// delivery is deliberately narrowed too, same as the sweep's. `source` doubles
// as the banner policy: only
// `'open-window-failed'` dispatches `sagaError` (a `windows.create` failure is
// the wallet's own doing, with nothing else to tell the user). Every other
// source — today just the sweep — is dapp-triggerable and console-only,
// matching the precedent in `sdk-methods.ts`'s `reportCapacityRefusal`: a
// banner mounted route-independently over every approval screen must not
// fire for a request the user cannot act on, and in close-as-wake the sweep's
// enumeration resolves inside another cancel's own grace, so it would often
// paint over an ordinary close or a live signing prompt.
export async function failRequestOnWindowError(
store: MainStore,
requestId: string,
source: SagaErrorSource = 'open-window-failed'
): Promise<void> {
const request = selectOpenRequests(store.getState()).find(
openRequest => openRequest.requestId === requestId
);

if (request == null) {
return;
}

store.dispatch(windowRequestResponded({ requestId }));
// The store-free half of `failRequestOnWindowError`: origin check, direct
// send, `deliverViaOrigin` fallback. Takes a snapshot row rather than reading
// the store, so it works from a saga (no store access there) as much as from
// a handler that has one — `resetVaultSaga`'s cancel-then-clear (spec §8.3)
// shares it with the caller below.
export type CancelDeliveryRow = Pick<
OpenRequest,
'requestId' | 'tabId' | 'origin' | 'method' | 'frameId'
>;

const { tabId, origin, method, frameId } = request;
export async function deliverCancelResponse(
row: CancelDeliveryRow,
logSource: SagaErrorSource
): Promise<number> {
const { requestId, tabId, origin, method, frameId } = row;
const action = buildCancelResponse(method, requestId);

// #1484: verify the tab still hosts the requesting origin BEFORE sending.
Expand All @@ -253,7 +233,7 @@ export async function failRequestOnWindowError(
// Identifiers and origins only, matching sdk-response-to-tab's withheld-
// response log — never a URL.
console.error(
`${source}: target tab no longer hosts the requesting origin; response withheld`,
`${logSource}: target tab no longer hosts the requesting origin; response withheld`,
{ requestId, tabId, expectedOrigin: origin, liveOrigin, delivered }
);
} else {
Expand All @@ -265,7 +245,7 @@ export async function failRequestOnWindowError(
// Never the raw error: a `tabs.sendMessage` rejection can echo back a
// navigated-away tab's URL, and one of this window's own URLs carries a
// signMessage request's plaintext message as a query param.
console.error(`${source}: cancel delivery failed`, {
console.error(`${logSource}: cancel delivery failed`, {
requestId,
method,
tabId,
Expand All @@ -275,6 +255,49 @@ export async function failRequestOnWindowError(
}
}

return delivered;
}

// The trigger with no window event behind it — either `windows.create`
// rejected (no `windows.onRemoved` will ever fire for a window that never
// existed) or the startup sweep decided a hydrated 'open' row is orphaned
// (spec §8.1). Without this the dapp promise hangs until its own timeout
// (30 min by default).
//
// `source` defaults to the original trigger so the `open-window-failed` call
// site keeps its BANNER policy unchanged — its delivery does not: #1484's
// stale-tab check below applies to every source alike, so that call site's
// delivery is deliberately narrowed too, same as the sweep's. `source` doubles
// as the banner policy: only
// `'open-window-failed'` dispatches `sagaError` (a `windows.create` failure is
// the wallet's own doing, with nothing else to tell the user). Every other
// source — today just the sweep — is dapp-triggerable and console-only,
// matching the precedent in `sdk-methods.ts`'s `reportCapacityRefusal`: a
// banner mounted route-independently over every approval screen must not
// fire for a request the user cannot act on, and in close-as-wake the sweep's
// enumeration resolves inside another cancel's own grace, so it would often
// paint over an ordinary close or a live signing prompt.
export async function failRequestOnWindowError(
store: MainStore,
requestId: string,
source: SagaErrorSource = 'open-window-failed'
): Promise<void> {
const request = selectOpenRequests(store.getState()).find(
openRequest => openRequest.requestId === requestId
);

if (request == null) {
return;
}

store.dispatch(windowRequestResponded({ requestId }));

const { tabId, origin, method, frameId } = request;
const delivered = await deliverCancelResponse(
{ requestId, tabId, origin, method, frameId },
source
);

// The sweep (source === 'sweep-orphaned-requests') knowingly shares this
// same tombstone-before-delivery ordering — an accepted residual, not an
// oversight specific to the sweep.
Expand Down
6 changes: 5 additions & 1 deletion src/background/handlers/redux-actions.parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,11 @@ const EXCLUSIONS: ReadonlySet<string> = new Set(
// Background-only since WALLET-1424: the background owns the retry counter,
// so a page can no longer forge attempts or clear the count.
loginRetryCountActions.loginRetryCountIncremented,
loginRetryCountActions.loginRetryCountReseted
loginRetryCountActions.loginRetryCountReseted,
// Background-only (spec §8.3): `yield put` inside `resetVaultSaga` only, as
// part of the synchronous reset block. Never dispatched from the UI — a
// saga `put` never reaches `handleReduxAction` at all.
windowManagementActions.windowManagementReseted
].map(creator => creator.type)
);

Expand Down
18 changes: 17 additions & 1 deletion src/background/handlers/redux-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,33 @@ beforeEach(() => {

describe('handleReduxAction forwarding gate (fail-closed)', () => {
it('resetVault → dispatches and re-enables the onboarding flow', async () => {
// A fresh `resetVault(...)` is built here, not the wire action re-cast —
// the sender's own window id (absent for this tab-less sender) is
// attached from `MessageSender`, which the UI has no access to and could
// not be trusted to self-report even if it did.
const { store, dispatch } = makeStore();
const action = { type: resetVault.type };

const result = await handleReduxAction(action, trustedSender, store);

expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledWith(action);
expect(dispatch).toHaveBeenCalledWith(resetVault(undefined));
expect(enableOnboardingFlowMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({ handled: true, response: undefined });
});

it('resetVault from a tab sender → attaches the sender window id, excluded later by the saga', async () => {
const { store, dispatch } = makeStore();
const tabSender = {
...trustedSender,
tab: { id: 9, windowId: 7 }
} as Runtime.MessageSender;

await handleReduxAction({ type: resetVault.type }, tabSender, store);

expect(dispatch).toHaveBeenCalledWith(resetVault(7));
});

it('windowRequestWindowAttached → handled by its own branch, which verifies the window', async () => {
// It must reach the store (the Ledger hook dispatches it from a UI page),
// but through the branch that probes the window rather than through the
Expand Down
9 changes: 8 additions & 1 deletion src/background/handlers/redux-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,14 @@ export async function handleReduxAction(
}

if (action.type === resetVault.type) {
store.dispatch(action as unknown as ReduxAction);
// The sender's OWN window, from `MessageSender` rather than the wire
// payload — `ResetVaultPage` renders inside the signature-request and
// connect-to-app approval windows (`LockedRouter`), so `resetVaultSaga`'s
// window-removal set must exclude it: closing the window the reset was
// issued FROM would kill the page's own continuation
// (`closeWindowByReloadExtension`), and on Firefox/Safari that also skips
// `runtime.reload()`. Absent for a non-tab sender, hence optional.
store.dispatch(resetVault(sender.tab?.windowId));
await enableOnboardingFlow();
return { handled: true, response: undefined };
}
Expand Down
8 changes: 7 additions & 1 deletion src/background/redux/sagas/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import { SecretPhrase } from '@libs/crypto';
import { Account } from '@libs/types/account';

export const startBackground = createAction('START_BACKGROUND_SAGA');
export const resetVault = createAction('RESET_VAULT_SAGA');
// `senderWindowId` is attached by the background handler (`redux-actions.ts`,
// from `MessageSender`), never by the UI dispatcher — the UI's own
// `resetVault()` calls stay zero-arg. Optional: absent for a non-tab sender.
export const resetVault = createAction(
'RESET_VAULT_SAGA',
(senderWindowId?: number) => ({ payload: { senderWindowId } })
);
export const lockVault = createAction('LOCK_VAULT_SAGA');
export const openExportKeysWindow = createAction(
'OPEN_EXPORT_KEYS_WINDOW_SAGA'
Expand Down
Loading
Loading