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
+144 -29
View File
@@ -100,6 +100,107 @@ function tokenRefs(value) {
);
}
// A list of strings, for the fields whose entries are dereferenced as text:
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` in the background).
//
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// list names no site and a number in fraudContracts names no contract, so there
// is nothing to repair either to, and the empty list is a legitimate value that
// survives. The result is a fresh array of primitives, so it shares no
// structure with `saved`.
function textList(value) {
if (!Array.isArray(value)) return [];
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
//
// The container check these had (truthy and not an array) is not the floor:
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
// level below it. saveState() merges these maps per key and then per hostname
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
// rendered, which is why every save from then on failed while the UI looked
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
// revoke button (`list.filter()`), and the background's
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
// one a stored string would also answer a SUBSTRING match, so a corrupt map
// could widen a site permission rather than merely throw.
//
// An address key whose value is not a list of hostnames is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. A stored own
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
// can never be a wallet address, so it grants nothing either, and keeping it
// only keeps a value that saveState()'s merge would hand to the prototype
// setter on the next write. Keys are written with defineProperty so that no key
// reaching this function can consult a setter at all, whatever the rule above
// it becomes; mergeMapByKey() in src/shared/state.js writes the same way.
function siteMap(value) {
const out = {};
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames);
}
return out;
}
// An endpoint URL: non-empty text, or the fallback.
function url(value, fallback) {
return typeof value === "string" && value !== "" ? value : fallback;
}
// One remembered endpoint pair out of networkEndpoints, floored on the two
// fields applyChainSwitchFields() (src/shared/chainSwitchFields.js) assigns
// STRAIGHT ONTO s.rpcUrl / s.blockscoutUrl on the next chain switch: flooring
// the live fields alone would leave a non-string sitting one switch away from
// them. A field that is not text is deleted rather than replaced, so the
// switch falls through its own `|| net.defaultRpcUrl`. Anything else the pair
// carries is kept: a profile that has been on a build storing more per-network
// fields must not lose them by passing through this one.
function endpointPair(value) {
const pair = { ...(isRecord(value) ? value : {}) };
for (const field of ["rpcUrl", "blockscoutUrl"]) {
if (typeof pair[field] !== "string" || pair[field] === "") {
delete pair[field];
}
}
return pair;
}
// A list index into wallets / a wallet's addresses: a non-negative integer, or
// null for "nothing selected".
//
// hasValidAddress() (src/popup/viewRouter.js) guards the restore path with
// `state.wallets[state.selectedWallet] && …addresses[state.selectedAddress]`,
// which is safe for a stale INTEGER — out of range is undefined, and the `&&`
// short-circuits — and NOT safe for a string naming an Array.prototype member.
// `wallets["map"]` is truthy, so the guard does not short-circuit and
// `.addresses[…]` throws out of restoreView(): the dead popup. "length",
// "constructor" and "__proto__" answer the same way, and
// src/popup/views/confirmTx.js dereferences selectedWallet behind no guard at
// all.
function listIndex(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
// Write `key` as an own data property, never through a setter. Plain
// assignment of "__proto__" replaces the object's prototype and records no
// entry; every map built from stored keys goes through this.
function defineOwn(obj, key, value) {
Object.defineProperty(obj, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
}
// 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
@@ -195,8 +296,15 @@ function normalizePersisted(saved) {
out.networkId = isKnownNetworkId(saved.networkId)
? saved.networkId
: DEFAULT_STATE.networkId;
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
// Non-empty text or the default, never anything else. getProvider()
// (src/shared/balances.js) hands rpcUrl straight to `new
// JsonRpcProvider()`, which throws SYNCHRONOUSLY for a value that is not a
// string — out of src/popup/views/txStatus.js and src/popup/views/
// addWallet.js, neither of which is inside a try, and the first of which a
// stored `currentView: "wait-tx"` reaches through restoreView(). It is a
// scalar, so the type check is the whole fix.
out.rpcUrl = url(saved.rpcUrl, DEFAULT_STATE.rpcUrl);
out.blockscoutUrl = url(saved.blockscoutUrl, DEFAULT_STATE.blockscoutUrl);
// An actual object is required, not merely a truthy non-array: the code
// below and applyChainSwitchFields() index and ASSIGN INTO this value, and
// assigning a property to a string or a number is a silent no-op in
@@ -210,19 +318,16 @@ function normalizePersisted(saved) {
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
// defineProperty, not assignment: a stored map with an own
// "__proto__" key — which JSON can carry and assignment treats as the
// prototype setter — would otherwise replace this object's prototype
// and record no entry at all. Keys other than the known network ids
// are kept rather than dropped, so a profile that has been on a build
// with more networks does not lose their endpoints by passing through
// this one.
Object.defineProperty(out.networkEndpoints, netId, {
value: { ...rawEndpoints[netId] },
writable: true,
enumerable: true,
configurable: true,
});
// Keys other than the known network ids are kept rather than dropped,
// so a profile that has been on a build with more networks does not
// lose their endpoints by passing through this one. That is why an own
// "__proto__" key survives here where siteMap() drops it, and why the
// write has to go through defineOwn().
defineOwn(
out.networkEndpoints,
netId,
endpointPair(rawEndpoints[netId]),
);
}
// A profile written before this map existed carries exactly one pair of
// endpoints, belonging to whatever network it was last on. Adopt it as
@@ -247,14 +352,8 @@ function normalizePersisted(saved) {
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
? saved.activeAddress
: null;
out.allowedSites =
saved.allowedSites && !Array.isArray(saved.allowedSites)
? structuredClone(saved.allowedSites)
: {};
out.deniedSites =
saved.deniedSites && !Array.isArray(saved.deniedSites)
? structuredClone(saved.deniedSites)
: {};
out.allowedSites = siteMap(saved.allowedSites);
out.deniedSites = siteMap(saved.deniedSites);
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
@@ -287,16 +386,32 @@ function normalizePersisted(saved) {
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
out.fraudContracts = structuredClone(saved.fraudContracts || []);
// A list of contract addresses, floored the same way: send.js builds its
// fraud set as `(state.fraudContracts || []).map((a) => a.toLowerCase())`
// and filterTransactions() maps the same list through normalizeAddress(),
// so a stored string walks through the `|| []` and a stored number walks
// through an Array.isArray().
out.fraudContracts = textList(saved.fraudContracts);
out.tokenHolderCache = structuredClone(saved.tokenHolderCache || {});
out.theme = saved.theme || "system";
out.debugMode = saved.debugMode !== undefined ? saved.debugMode : false;
out.currentView = saved.currentView || null;
out.selectedWallet =
saved.selectedWallet !== undefined ? saved.selectedWallet : null;
out.selectedAddress =
saved.selectedAddress !== undefined ? saved.selectedAddress : null;
out.selectedToken = saved.selectedToken || null;
out.selectedWallet = listIndex(saved.selectedWallet);
out.selectedAddress = listIndex(saved.selectedAddress);
// "ETH", or a contract address, or null — never anything else. The popup
// restores onto "address-token" behind a truthiness check on this field and
// then dereferences it as text (`tokenId.toLowerCase()` in
// src/popup/views/addressToken.js, `state.selectedToken.toLowerCase()` in
// src/popup/views/receive.js), so a stored number is truthy, passes the
// restore gate, and throws on the screen it restores onto. Found by the
// sweep for this same defect class in
// https://git.eeqj.de/sneak/AutistMask/issues/362; floored to null, which
// is what the restore gate already treats as "nothing selected". The empty
// string was already falsy here and stays null.
out.selectedToken =
typeof saved.selectedToken === "string" && saved.selectedToken !== ""
? saved.selectedToken
: null;
out.viewData = structuredClone(saved.viewData || {});
out.viewStack = restorableStack(saved.viewStack, out.currentView);
return out;
+58 -3
View File
@@ -310,12 +310,26 @@ function mergeAddress(base, ours, theirs) {
// a key another page edited. Unlike an array's identity function, an object
// key can't collide with a different logical entry (Object.keys() is
// already deduplicated), so this needs no collision floor of its own.
//
// Every write goes through defineProperty rather than assignment. The keys are
// whatever the stored record carries, and plain assignment of "__proto__" —
// which JSON can carry and normalizePersisted() keeps for networkEndpoints —
// replaces this object's prototype and records no entry. That would undo one
// layer downstream exactly what defineOwn() does in
// src/shared/persistedState.js.
function mergeMapByKey(base, ours, theirs, mergeLeaf) {
base = base || {};
ours = ours || {};
theirs = theirs || {};
const result = {};
const seen = new Set();
const put = (key, value) =>
Object.defineProperty(result, key, {
value: value,
writable: true,
enumerable: true,
configurable: true,
});
for (const key of Object.keys(theirs)) {
seen.add(key);
@@ -323,16 +337,16 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
const inOurs = Object.prototype.hasOwnProperty.call(ours, key);
if (inBase && !inOurs) continue; // this page deleted the whole entry
if (inOurs) {
result[key] = mergeLeaf(base[key], ours[key], theirs[key]);
put(key, mergeLeaf(base[key], ours[key], theirs[key]));
} else {
result[key] = theirs[key];
put(key, theirs[key]);
}
}
for (const key of Object.keys(ours)) {
if (seen.has(key)) continue;
if (!Object.prototype.hasOwnProperty.call(base, key)) {
result[key] = ours[key];
put(key, ours[key]);
}
}
@@ -522,11 +536,51 @@ async function saveStateOnce() {
// begins, so each one only ever sees the true live state at its turn.
let saveQueue = Promise.resolve();
// Where a failed save is REPORTED, set once by the context that has a screen
// to say it on (src/popup/index.js).
//
// A save that fails must not fail silently. showView() fires saveState() on
// every navigation without awaiting it, and the queue below has to attach a
// rejection handler to keep advancing — so a failing save was swallowed
// entirely: no throw, no message, nothing on screen. The wallet kept running
// against storage that was rejecting every write, which is the data-loss half
// of https://git.eeqj.de/sneak/AutistMask/issues/362. The awaited callers were
// no better off: `await saveState()` inside an unguarded event handler surfaces
// in the console and nowhere the user looks.
//
// This is the "tell the user" half; the other half is the floor in
// normalizePersisted(), which stops the malformed-record cause from arising in
// the first place. Both, because a floor only covers the causes it knows about
// and storage can still fail for reasons of its own (quota, a revoked
// permission, a record a newer build wrote).
let saveFailureHandler = null;
function onSaveFailure(fn) {
saveFailureHandler = fn;
}
function reportSaveFailure(err) {
log.errorf("state: saving failed, changes were NOT persisted:", err);
if (!saveFailureHandler) return;
try {
saveFailureHandler(err);
} catch (e) {
// The reporter is the last thing standing between a failed save and
// silence; a reporter that throws must not become an unhandled
// rejection of its own on top of it.
log.errorf("state: the save-failure reporter itself failed:", e);
}
}
function saveState() {
const turn = saveQueue.then(saveStateOnce);
// The queue must advance even when a save rejects, or every save after
// it queues behind a promise that never settles.
saveQueue = turn.catch(() => {});
// Every failed save is reported, whether or not the caller awaited this
// one. The returned promise still rejects, so a caller that DOES await
// keeps its own error handling.
turn.catch(reportSaveFailure);
return turn;
}
@@ -574,6 +628,7 @@ function currentAddress() {
module.exports = {
state,
saveState,
onSaveFailure,
loadState,
currentAddress,
currentNetwork,
+34 -27
View File
@@ -26,35 +26,42 @@
//
// What is checked HERE is what nothing downstream can floor: the wallet list,
// the version, and the network id that keys an object. Every other field is
// normalizePersisted()'s to make safe, and what that function does today is
// NOT uniform. The four kinds of floor it applies, listed so a reader can tell
// which one a given field has without reading it off:
// normalizePersisted()'s to make safe, and what that function does is NOT
// uniform across the record.
//
// Type-checked, container AND entries: trackedTokens, each address's
// tokenBalances, networkId, networkEndpoints, activeAddress, viewStack.
// These are the fields something dereferences structurally — iterated,
// indexed, assigned into, or .toLowerCase()'dwhere a truthy value of
// the wrong type throws on the first read. The entries matter as much as
// the container: [1, 2] IS a list, and `t.address` is one level below the
// Array.isArray(). What is checked on an ENTRY is the field the check
// exists for and no more — for trackedTokens and tokenBalances that is
// `address` alone; the rest of an entry is taken verbatim. So an entry's
// `decimals` and `balance` may be null, which is how balances.js records
// that nothing knows the token's scale
// (https://git.eeqj.de/sneak/AutistMask/issues/349), and every reader
// handles that null rather than being defended from it here.
// Container shape only: allowedSites, deniedSites. A falsy value or a list
// becomes {}; anything else is taken as stored and the entries are not
// checked.
// `saved.x || default`, no type check: rpcUrl, blockscoutUrl,
// lastBalanceRefresh, fraudContracts, tokenHolderCache, theme,
// currentView, selectedToken, viewData.
// Present-or-default, value taken verbatim: every boolean flag,
// dustThresholdGwei, selectedWallet, selectedAddress.
// WHICH FLOOR A GIVEN FIELD HAS IS NOT WRITTEN HERE. It is
// tests/persistedFieldContract.test.js: one row per persisted field, naming
// the property that field's floor is claimed to have, and PROVING it by
// driving the real code with hostile valuesthe gate for a field the gate
// refuses, normalizePersisted() for a field it floors, and, for a field whose
// only defence is that nothing dereferences it structurally, a boot of the
// real popup entry point onto EVERY view the popup can reopen onto.
//
// A field added to the record needs a check here or a floor there, chosen by
// what reads it: anything dereferenced structurally needs the type check, and
// neither of the last two kinds is one.
// That last part is the whole point, because this defect class lives on the
// RESTORE path and not on Home. Take the claim NARROWLY, exactly as that file
// states it: what those boots prove is no structural dereference on the code
// paths a WHOLLY-CORRUPTED PROFILE takes — which is not every path a stored
// record takes. Not driven: any pairing of values the four slots do not
// produce, a view only forward navigation opens, anything behind a click, and
// everything a healthy profile reaches. Within that boundary the verdict is
// unconditional, including a dereference that takes two corrupted fields at
// once. That suite also goes red on a field that gains a floor while its row
// still claims it has none, and on a field added to PERSISTED_FIELDS with no
// row at all.
//
// That test exists because this comment did not work. It carried a
// hand-written justification per field, and it shipped a false one in three
// consecutive changes — a different field each time, each caught only by a
// reviewer re-deriving thirty fields by hand. A claim nobody can execute is
// worse than no claim, because it is believed.
//
// The trap is worth stating here, since it is what all three got wrong: a
// check on a CONTAINER is not a check on its ENTRIES, and the dereference is
// one level below the container. `[1, 2]` is a list, `{"0x…": "notalist"}` is
// a record, and `{"currentView":"success-tx","viewData":{"hash":"0x1"}}`
// passes the restore gate and throws on the address the renderer below it
// reads. A field added to the record needs a decision about its entries as
// well as its shape — and then a row in that test.
const { isKnownNetworkId } = require("./networks");