fix: version stored state, validate its shape, and give a corrupt blob a way out (closes #311)
Stored state had no version and no structural validation, so a corrupt blob produced a completely blank popup with no message and no recovery control, and made every dApp RPC call from every page answer a generic -32603. There was no reset or wipe control anywhere in the UI. saveState() now stamps a schema version and loadState() validates the shape. A version it does not understand, or a wallets array it cannot parse, lands on a recovery screen that names the problem, offers the stored record verbatim for export, and offers a destructive reset behind a typed confirmation. Unversioned but valid state -- which every existing install has -- migrates in place and keeps working; it is never shown a wipe prompt. A dApp call against unusable state answers -32007, which EIP-1474 leaves unassigned, rather than -32603. networkById() refuses an unknown id loudly instead of returning mainnet, and networkId is validated so a corrupt value cannot be used as an object key. Fields the gate does not refuse are floored by type, container and entries both: a malformed trackedTokens or tokenBalances entry is dropped rather than dereferenced. Verified by an independent sweep of 1152 corrupt blobs producing no blank popup, with the same harness showing 9 blanks against the previous revision.
This commit was merged in pull request #360.
This commit is contained in:
@@ -10,8 +10,14 @@
|
||||
// reaching it anyway and being served DEFAULT_STATE.
|
||||
|
||||
const { DEFAULT_RPC_URL, DEFAULT_BLOCKSCOUT_URL } = require("./constants");
|
||||
// Dependency-free constant module; safe to pull into a background bundle.
|
||||
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
|
||||
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,
|
||||
@@ -46,7 +52,10 @@ const DEFAULT_STATE = {
|
||||
// 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.
|
||||
// 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([
|
||||
@@ -58,6 +67,31 @@ const PERSISTED_FIELDS = Object.keys(DEFAULT_STATE)
|
||||
"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.
|
||||
function tokenRefs(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(
|
||||
(entry) => isRecord(entry) && typeof entry.address === "string",
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -107,11 +141,52 @@ function restorableStack(stored, currentView) {
|
||||
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;
|
||||
out.trackedTokens = structuredClone(saved.trackedTokens || []);
|
||||
out.networkId = saved.networkId || DEFAULT_STATE.networkId;
|
||||
// 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;
|
||||
out.rpcUrl = saved.rpcUrl || DEFAULT_STATE.rpcUrl;
|
||||
out.blockscoutUrl = saved.blockscoutUrl || DEFAULT_STATE.blockscoutUrl;
|
||||
// An actual object is required, not merely a truthy non-array: the code
|
||||
@@ -127,7 +202,19 @@ function normalizePersisted(saved) {
|
||||
: {};
|
||||
out.networkEndpoints = {};
|
||||
for (const netId of Object.keys(rawEndpoints)) {
|
||||
out.networkEndpoints[netId] = { ...rawEndpoints[netId] };
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
// A profile written before this map existed carries exactly one pair of
|
||||
// endpoints, belonging to whatever network it was last on. Adopt it as
|
||||
@@ -140,7 +227,18 @@ function normalizePersisted(saved) {
|
||||
};
|
||||
}
|
||||
out.lastBalanceRefresh = saved.lastBalanceRefresh || 0;
|
||||
out.activeAddress = saved.activeAddress || null;
|
||||
// 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 =
|
||||
saved.allowedSites && !Array.isArray(saved.allowedSites)
|
||||
? structuredClone(saved.allowedSites)
|
||||
|
||||
Reference in New Issue
Block a user