fix: filter the restored view stack against RESTORABLE_VIEWS (closes #224)
All checks were successful
check / check (push) Successful in 48s

restoreView() refuses to reopen the popup ONTO a non-restorable view, but
the stack behind it was restored verbatim, so a screen the popup will not
render -- export-privkey, show-phrase -- could sit in it. Back then landed
on a view the popup had declined to restore, and show-phrase has no Back
control to leave by. No secret is exposed: those screens are empty
precisely because nothing is restored into them. This is navigation, not
disclosure.

loadState() now truncates the stored stack at the first entry outside
RESTORABLE_VIEWS rather than splicing that entry out, so the result stays a
prefix of what was stored and every surviving entry keeps exactly the Back
target it had; splicing would silently re-point the entry above the hole at
a different screen. A stack truncated to nothing under a restorable
non-root view gets main beneath it, so Back always has somewhere to go.
Stacks with no unrenderable entry are restored unchanged. A stored stack
that is not an array runs through the same rule instead of returning early,
so a corrupt stack does not fall back on the goBack() behaviour the
explicit main exists in order not to depend on.

The filter is on load, not on save: the live in-session stack is
legitimate, since the screen really is rendered while the popup is open,
and only a load-side filter also repairs the stacks already in storage,
including ones written before a view left the set.

Out of scope, tracked separately: a restorable view sitting in the stack is
unhidden by goBack() without being re-rendered, so it can still come up
blank on a freshly reopened popup. That is a general navigation defect,
independent of this filter, and is
#268.
This commit is contained in:
2026-08-12 09:32:11 +00:00
parent 5af89a1b63
commit 5442b3ce67
3 changed files with 154 additions and 1 deletions

View File

@@ -2,6 +2,8 @@
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const storageApi =
typeof browser !== "undefined"
@@ -43,6 +45,39 @@ const state = {
viewStack: [],
};
// Keep only the leading run of stored views the popup is willing to render.
//
// restoreView() refuses to reopen ONTO a non-restorable view, but the stack
// behind it used to be restored verbatim, so Back could walk onto a screen
// whose content is deliberately never re-rendered — and "show-phrase" has no
// Back control to leave by. Truncating at the first such entry instead of
// splicing it out keeps the result a prefix of the stored stack, so every
// surviving entry's Back target is exactly the one it had; splicing would
// silently re-point the entry above the hole at a different screen.
//
// Filtering happens here on load rather than in saveState(): the live
// in-session stack is legitimate (the screen really is rendered while the
// popup is open), and only a load-side filter also repairs the stacks
// already in storage, including ones written before a view left the set.
function restorableStack(stored, currentView) {
// A stored stack that is missing or not an array keeps nothing, but it
// still goes through the never-empty rule below rather than returning
// early: otherwise a corrupt stack would depend on exactly the goBack()
// fallback that the explicit ["main"] exists in order not to depend on.
const source = Array.isArray(stored) ? stored : [];
const cut = source.findIndex((view) => !RESTORABLE_VIEWS.has(view));
const kept = cut === -1 ? source.slice() : source.slice(0, cut);
// A view restored below the root still needs somewhere for Back to go.
if (
kept.length === 0 &&
currentView !== "main" &&
RESTORABLE_VIEWS.has(currentView)
) {
return ["main"];
}
return kept;
}
// Return the network configuration for the currently selected network.
function currentNetwork() {
return networkById(state.networkId);
@@ -150,7 +185,7 @@ async function loadState() {
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
state.selectedToken = saved.selectedToken || null;
state.viewData = saved.viewData || {};
state.viewStack = Array.isArray(saved.viewStack) ? saved.viewStack : [];
state.viewStack = restorableStack(saved.viewStack, state.currentView);
}
}