Files
AutistMask/src/shared/persistedState.js
T
clawbot a098bb0c32
check / check (push) Successful in 42s
e2e / e2e-chrome (push) Successful in 1m45s
e2e / e2e-firefox (push) Successful in 31s
fix: floor malformed allowedSites, fraudContracts and selectedToken entries (closes #362)
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.
2026-08-23 23:06:17 +02:00

426 lines
21 KiB
JavaScript

// The shape of the persisted profile, and the normalization every read of it
// goes through. No singleton, no storage access, no browser API: just the
// record definition and pure functions over it.
//
// Split out of state.js so that a context which must never touch the
// module-level `state` singleton can still speak the same record format.
// src/background/state.js is that context — the MV3 service worker never
// populates the singleton, and every defect in
// https://git.eeqj.de/sneak/AutistMask/issues/324 came from background code
// reaching it anyway and being served DEFAULT_STATE.
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
const { isKnownNetworkId } = require("./networks");
const { STATE_SCHEMA_VERSION } = require("./stateSchema");
// Dependency-free constant module. It lives under src/shared/ rather than
// src/popup/ precisely because this module is in the background bundle: a
// popup-path module reached from the worker is the shape the prohibition in
// script/lib/forbiddenBundleInputs.js exists to keep out, even when the
// particular module is harmless.
const { RESTORABLE_VIEWS } = require("./restorableViews");
const DEFAULT_STATE = {
hasWallet: false,
wallets: [],
trackedTokens: [],
networkId: "mainnet",
rpcUrl: DEFAULT_RPC_URL,
blockscoutUrl: DEFAULT_BLOCKSCOUT_URL,
// Endpoints remembered per network: { [networkId]: { rpcUrl,
// blockscoutUrl } }. rpcUrl/blockscoutUrl above are the live endpoints
// of the active network; this is what the others are restored from
// when the active network changes. See applyChainSwitchFields().
networkEndpoints: {},
lastBalanceRefresh: 0,
activeAddress: null,
allowedSites: {},
deniedSites: {},
rememberSiteChoice: true,
showZeroBalanceTokens: true,
hideSpoofedSymbols: true,
hideLowHolderTokens: true,
hideFraudContracts: true,
hideDustTransactions: true,
dustThresholdGwei: 100000,
utcTimestamps: false,
fraudContracts: [],
tokenHolderCache: {},
theme: "system",
debugMode: false,
};
// Every field written to and read from the single "autistmask" storage key.
// hasWallet is deliberately excluded from the diffing/merge logic in
// state.js — like loadState() does, it is always derived from `wallets`,
// never carried as an independent value. schemaVersion is excluded for the
// same reason and is absent from DEFAULT_STATE for it: it describes the
// record rather than being part of it, and every write stamps the current
// value rather than diffing whatever was read.
const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
.filter((key) => key !== "hasWallet")
.concat([
"currentView",
"selectedWallet",
"selectedAddress",
"selectedToken",
"viewData",
"viewStack",
]);
function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
// A list of token references, as everything downstream dereferences them:
// `t.address.toLowerCase()`, with no guard of its own (src/shared/balances.js,
// src/popup/views/helpers.js, and every view that shows a balance line).
//
// Both the container AND the entries, because they are separate defects. A
// container check alone leaves a well-formed list of malformed entries walking
// through to a dereference one level below the check, which is the same blank
// popup: `[1, 2]` and `[{}]` are lists.
//
// A malformed entry is DROPPED rather than repaired: a token reference with no
// address identifies nothing, so there is no value to repair it to, and the
// alternative — refusing the whole record — sends a user whose wallets are
// perfectly readable to an export-or-erase screen over a token list. An entry
// that is a record with a text address is kept verbatim, extra fields and all.
//
// Verbatim is load-bearing for the fields BESIDE the address. A tokenBalances
// entry carries `decimals: null` and `balance: null` when nothing knows the
// token's scale (src/shared/balances.js,
// https://git.eeqj.de/sneak/AutistMask/issues/349), and those nulls are the
// record that the value is unknown. Only `address` decides whether an entry
// survives, so an unknown-scale holding is kept — flooring a null here to some
// default would put the guess back one layer down from where it was removed.
function tokenRefs(value) {
if (!Array.isArray(value)) return [];
return value.filter(
(entry) => isRecord(entry) && typeof entry.address === "string",
);
}
// 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
// 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;
}
// Turn a raw stored (or missing) record into the full, defaulted shape
// loadState() used to assign directly onto `state`. A pure function so that
// saveState() can apply it too: the fields THIS page did not change still have
// to come from storage in their loaded-and-normalized form, not as the raw
// bytes another page (or an old release) left there — otherwise a legacy shape
// a load has always self-healed in memory (a missing networkEndpoints map, an
// out-of-range flag) is dropped right back into storage unfixed every time the
// page that DID normalize it saves something unrelated, because that field's
// value never "changed" for that page to notice.
//
// The result never shares structure with `saved`, so a caller may mutate it
// freely: it is the detached record every per-call read in the background is
// built on.
function normalizePersisted(saved) {
saved = saved || {};
const out = {};
// Every write goes out at the current version. That IS the migration for
// the unversioned records every install in the field holds: version 1 is
// the shape that shipped unversioned, so a record that validated is
// carried forward simply by being stamped. A record this build does NOT
// understand never reaches here — assertStateUsable() refuses it on the
// read path first (src/shared/stateSchema.js).
out.schemaVersion = STATE_SCHEMA_VERSION;
out.wallets = structuredClone(saved.wallets || []);
// Derived, never trusted verbatim off storage — see loadState().
out.hasWallet = out.wallets.length > 0;
// Each address's token holdings, floored to a list of token records on the
// detached copy above. Every reader iterates it behind a `|| []` that only
// covers an ABSENT value, and then dereferences `t.address.toLowerCase()`
// and `t.balance` — so a stored string iterates as characters, a number
// throws on the iterator, and a null entry throws on the field.
//
// This field specifically, because refreshBalances() writes it WHOLESALE
// rather than merging into it: a write that only partly lands is the live
// cause https://git.eeqj.de/sneak/AutistMask/issues/311 names, and this is
// where it lands. The wallet list itself is the gate's (stateSchema.js);
// what is below an address record is not, and gets floored here.
if (Array.isArray(out.wallets)) {
for (const wallet of out.wallets) {
if (!isRecord(wallet) || !Array.isArray(wallet.addresses)) continue;
for (const addr of wallet.addresses) {
if (!isRecord(addr)) continue;
addr.tokenBalances = tokenRefs(addr.tokenBalances);
}
}
}
// An actual list of token records is required, not merely a truthy value
// and not merely a list: everything downstream iterates this and
// dereferences `token.address`, so a stored string or object walks through
// a `|| []`, and a list of numbers walks through an Array.isArray(), and
// both throw on the first read — the blank popup from the issue, for a
// profile whose wallets are perfectly fine. An empty list is a legitimate
// value and survives.
out.trackedTokens = structuredClone(tokenRefs(saved.trackedTokens));
// The loud refusal for an unknown id is assertStateUsable(); this is the
// floor under it. networkId is an object KEY into networkEndpoints below,
// so a value that is not a network in networks.js must never get that far
// — "__proto__" would set the map's prototype instead of an own key, and
// the user's endpoint would silently not be recorded.
out.networkId = isKnownNetworkId(saved.networkId)
? saved.networkId
: DEFAULT_STATE.networkId;
// 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
// sloppy mode. Copied rather than referenced, nested pairs included, so
// normalizing never mutates the object a caller handed in.
const rawEndpoints =
typeof saved.networkEndpoints === "object" &&
saved.networkEndpoints !== null &&
!Array.isArray(saved.networkEndpoints)
? saved.networkEndpoints
: {};
out.networkEndpoints = {};
for (const netId of Object.keys(rawEndpoints)) {
// 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
// that network's remembered pair, so a custom endpoint set on the old
// build is not lost by the first switch away and back.
if (!out.networkEndpoints[out.networkId]) {
out.networkEndpoints[out.networkId] = {
rpcUrl: out.rpcUrl,
blockscoutUrl: out.blockscoutUrl,
};
}
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
// A non-empty address, or null, never anything else: this is passed to
// address.slice() and compared against stored addresses, so a stored
// number or object walks through a `|| null` and throws on the first
// render. The empty string is text but it is not an address, and it must
// become null rather than survive: init() auto-selects the first address
// only on a STRICT null, so a stored "" would leave the popup with no
// address ever selected. Nothing in src/ writes one, and this keeps the
// behaviour the `|| null` this check replaced already had.
out.activeAddress =
typeof saved.activeAddress === "string" && saved.activeAddress !== ""
? saved.activeAddress
: null;
out.allowedSites = siteMap(saved.allowedSites);
out.deniedSites = siteMap(saved.deniedSites);
out.rememberSiteChoice =
saved.rememberSiteChoice !== undefined
? saved.rememberSiteChoice
: true;
out.showZeroBalanceTokens =
saved.showZeroBalanceTokens !== undefined
? saved.showZeroBalanceTokens
: true;
// A profile written before this setting existed has no key for it. It
// is a safety filter, so absent must load as on, not as undefined.
out.hideSpoofedSymbols =
saved.hideSpoofedSymbols !== undefined
? saved.hideSpoofedSymbols
: true;
out.hideLowHolderTokens =
saved.hideLowHolderTokens !== undefined
? saved.hideLowHolderTokens
: true;
out.hideFraudContracts =
saved.hideFraudContracts !== undefined
? saved.hideFraudContracts
: true;
out.hideDustTransactions =
saved.hideDustTransactions !== undefined
? saved.hideDustTransactions
: true;
out.dustThresholdGwei =
saved.dustThresholdGwei !== undefined
? saved.dustThresholdGwei
: 100000;
out.utcTimestamps =
saved.utcTimestamps !== undefined ? saved.utcTimestamps : false;
// 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 = 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;
}
module.exports = {
DEFAULT_STATE,
PERSISTED_FIELDS,
normalizePersisted,
restorableStack,
};