fix: floor malformed allowedSites, fraudContracts and selectedToken entries (closes #362)
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s

A stored allowedSites whose value was not a list rendered a working popup and then made every subsequent save fail silently, so the user operated a wallet that persisted nothing -- worse than a blank popup, which is at least visibly broken. fraudContracts and selectedToken had the same shape: a container floored by truthiness or not at all, while its entries were dereferenced. Entries are now floored as well as containers, following the idiom #311 established, and a failed save raises a persistent banner instead of vanishing into a swallowed rejection.

The per-field justifications that used to live in a hand-written header are replaced by a contract test that drives each field's hostile and falsy values through a real popup boot, so a claim about a field answers to the code rather than to prose. Its guarantee is stated narrowly and deliberately: no structural dereference on the code paths a wholly-corrupted profile takes, which is not every path a stored record takes. The paths it does not drive are named where the claim is made, and are tracked in #379.
This commit was merged in pull request #366.
This commit is contained in:
2026-08-23 23:06:17 +02:00
parent 45500e66cf
commit a098bb0c32
14 changed files with 2100 additions and 354 deletions
+78 -3
View File
@@ -53,12 +53,17 @@ function resetRenderedViews() {
const ALWAYS_RENDER_ON_BACK = new Set(["main"]);
// Views that render an address the user picked and cannot be rendered
// without one.
// without one. "confirm-tx" is here because its Sign button dereferences
// `state.wallets[state.selectedWallet].encryptedSecret`
// (src/popup/views/confirmTx.js) behind no guard of its own — a screen that
// can only throw when the user presses its one button must not be restored
// onto.
const ADDRESS_VIEWS = new Set([
"address",
"address-token",
"receive",
"transaction",
"confirm-tx",
]);
function needsAddress(view) {
@@ -74,6 +79,73 @@ function hasValidAddress(state) {
);
}
// The stored viewData ENTRIES each branch below dereferences, as opposed to
// the one field it gates on.
//
// A gate on a single truthy field checks the container, not the entries, and
// the dereference is one level below it: a stored `{"currentView":
// "success-tx","viewData":{"hash":"0x1"}}` passes `data.hash` and then throws
// on `address.toLowerCase()` inside addressTitle() (src/popup/views/
// helpers.js), out of restoreView(), which src/popup/index.js does not guard —
// so the rest of popup init never runs. txStatus.restoreWait() has checked its
// own branch's fields since it was written; these are the other four.
//
// Only what actually throws is required. Fields that are compared,
// concatenated or escaped coerce (escapeHtml() and displaySymbol() both
// String() their argument), so requiring them would refuse a restorable screen
// over a cosmetic value.
function isText(value) {
return typeof value === "string";
}
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// An address handed to renderAddressHtml()/addressTitle(): both reach
// `address.slice()` and `address.toLowerCase()` with no guard.
function isAddressText(value) {
return isText(value);
}
// Decoded calldata, as decodedDetailsHtml() (src/popup/views/txStatus.js)
// walks it: `for (const d of decoded.details)` needs an iterable, and each
// entry's `address` reaches toAddressHtml(). Absent or falsy is the ordinary
// case and short-circuits before either.
function isRenderableDecoded(value) {
if (!value) return true;
if (!isRecord(value)) return false;
if (!value.details) return true;
if (!Array.isArray(value.details)) return false;
return value.details.every(
(entry) =>
isRecord(entry) && (!entry.address || isAddressText(entry.address)),
);
}
// The pending transaction confirmTx.show() renders: `token` reaches
// renderAddressHtml() when it is not "ETH", and `from`/`to` reach
// addressTitle(), makeBlockie() and getLocalWarnings().
function isRenderablePendingTx(value) {
return (
isRecord(value) &&
isText(value.token) &&
isAddressText(value.from) &&
isAddressText(value.to)
);
}
// The stored transaction transactionDetail.render() shows. contractAddress is
// optional on an ETH transfer, and reaches addressDotHtml() when it is there.
function isRenderableTx(value) {
return (
isRecord(value) &&
isAddressText(value.from) &&
isAddressText(value.to) &&
(!value.contractAddress || isAddressText(value.contractAddress))
);
}
// Render `view` from persisted state. Each view module shows itself, so a
// true return means the view is both rendered and on screen.
//
@@ -107,11 +179,11 @@ function renderView(view, state, views) {
views.settingsAddToken.show();
return true;
case "confirm-tx":
if (!data.pendingTx) return false;
if (!isRenderablePendingTx(data.pendingTx)) return false;
views.confirmTx.restore();
return true;
case "transaction":
if (!data.tx) return false;
if (!isRenderableTx(data.tx)) return false;
views.transactionDetail.render();
return true;
case "wait-tx":
@@ -120,10 +192,13 @@ function renderView(view, state, views) {
return Boolean(views.txStatus.restoreWait());
case "success-tx":
if (!data.hash) return false;
if (!isAddressText(data.to)) return false;
if (!isRenderableDecoded(data.decoded)) return false;
views.txStatus.renderSuccess();
return true;
case "error-tx":
if (!data.message) return false;
if (!isAddressText(data.to)) return false;
views.txStatus.renderError();
return true;
default: