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:
265
src/shared/stateSchema.js
Normal file
265
src/shared/stateSchema.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// The version stamped on the stored profile, and the shape check every read
|
||||
// of one goes through.
|
||||
//
|
||||
// Storage is the one input to this extension that nobody validated. A profile
|
||||
// carried no version at all, so there was no way to tell a record this build
|
||||
// understands from one a later build wrote, and loadState() coerced scalars
|
||||
// while trusting the structure — so a `wallets` that was a string, or an array
|
||||
// of nulls, or a later schema's wallet records, reached the popup and threw on
|
||||
// the first dereference. The popup rendered NOTHING: no view, no message, no
|
||||
// control, and no way out from inside the product
|
||||
// (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
||||
//
|
||||
// Two separate jobs, deliberately not merged:
|
||||
//
|
||||
// stateProblem() / assertStateUsable() refuse a record this build cannot
|
||||
// safely reason about, loudly, naming the problem in a
|
||||
// sentence that goes on screen. This is the gate.
|
||||
// normalizePersisted() (persistedState.js) self-heal a record that IS
|
||||
// usable: absent fields, legacy shapes, out-of-range flags.
|
||||
//
|
||||
// The gate runs FIRST, on the raw stored bytes, before normalization has a
|
||||
// chance to paper over a record whose meaning nobody can vouch for. A blob
|
||||
// that fails it is left in storage untouched — it is the user's only copy of
|
||||
// whatever it holds, and the recovery screen exports it before offering to
|
||||
// erase it.
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// 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()'d — where 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().
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
const { isKnownNetworkId } = require("./networks");
|
||||
|
||||
// Bump this when the MEANING of a stored field changes, and add the migration
|
||||
// that carries the older version forward. Adding a field with a defaulted
|
||||
// absent value is not a bump: normalizePersisted() already handles that, and
|
||||
// bumping for it would send every older install to the recovery screen for no
|
||||
// reason.
|
||||
//
|
||||
// Version 1 is the shape that shipped unversioned. An unversioned record is
|
||||
// therefore version 1, not a defect — see migrationNeeded() below.
|
||||
const STATE_SCHEMA_VERSION = 1;
|
||||
|
||||
// Thrown by every read path that finds a record it cannot use. `problem` is
|
||||
// the sentence shown to the user; `message` carries the same text so a log
|
||||
// line or a rethrow is not empty.
|
||||
class StateUnusableError extends Error {
|
||||
constructor(problem) {
|
||||
super(problem);
|
||||
this.name = "StateUnusableError";
|
||||
this.problem = problem;
|
||||
}
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Own properties only, everywhere in this file. `saved` comes from storage as
|
||||
// parsed JSON, so `saved.constructor` and `saved.__proto__` answer from the
|
||||
// prototype chain for a record that carries neither — a check written as a
|
||||
// plain truthiness test can be satisfied by Object.prototype rather than by
|
||||
// anything the user's profile actually contains.
|
||||
function has(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function ordinal(index) {
|
||||
return String(index + 1);
|
||||
}
|
||||
|
||||
function describeType(value) {
|
||||
if (value === null) return "null";
|
||||
if (Array.isArray(value)) return "a list";
|
||||
return "a " + typeof value;
|
||||
}
|
||||
|
||||
// One address record, as every screen dereferences it.
|
||||
function addressProblem(addr, walletIndex, addrIndex) {
|
||||
const where =
|
||||
"address " +
|
||||
ordinal(addrIndex) +
|
||||
" of wallet " +
|
||||
ordinal(walletIndex) +
|
||||
" in the saved data";
|
||||
if (!isPlainObject(addr)) {
|
||||
return "The " + where + " is " + describeType(addr) + ", not a record.";
|
||||
}
|
||||
if (typeof addr.address !== "string" || addr.address === "") {
|
||||
return "The " + where + " has no address.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function walletProblem(wallet, index) {
|
||||
const where = "Wallet " + ordinal(index) + " in the saved data";
|
||||
if (!isPlainObject(wallet)) {
|
||||
return where + " is " + describeType(wallet) + ", not a wallet record.";
|
||||
}
|
||||
if (!Array.isArray(wallet.addresses)) {
|
||||
return where + " has no list of addresses.";
|
||||
}
|
||||
if (has(wallet, "name") && typeof wallet.name !== "string") {
|
||||
return where + " has a name that is not text.";
|
||||
}
|
||||
for (let i = 0; i < wallet.addresses.length; i++) {
|
||||
const problem = addressProblem(wallet.addresses[i], index, i);
|
||||
if (problem) return problem;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function versionProblem(saved) {
|
||||
// No version field at all is the shape every install in the field has:
|
||||
// no build ever wrote one. It is version 1, and it is migrated in place.
|
||||
if (!has(saved, "schemaVersion")) return null;
|
||||
const version = saved.schemaVersion;
|
||||
if (
|
||||
typeof version !== "number" ||
|
||||
!Number.isInteger(version) ||
|
||||
version < 1
|
||||
) {
|
||||
return (
|
||||
"The saved data carries a schema version AutistMask does not" +
|
||||
" recognize (" +
|
||||
JSON.stringify(version) +
|
||||
")."
|
||||
);
|
||||
}
|
||||
if (version > STATE_SCHEMA_VERSION) {
|
||||
return (
|
||||
"The saved data was written by a newer version of AutistMask" +
|
||||
" (schema version " +
|
||||
version +
|
||||
"; this build understands version " +
|
||||
STATE_SCHEMA_VERSION +
|
||||
")."
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reason this build cannot use `saved`, as a sentence for the user, or
|
||||
* null when it can.
|
||||
*
|
||||
* @param {*} saved the raw record from storage, or undefined for a fresh
|
||||
* install.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stateProblem(saved) {
|
||||
// Nothing stored is a first run, not a defect.
|
||||
if (saved === undefined || saved === null) return null;
|
||||
if (!isPlainObject(saved)) {
|
||||
return (
|
||||
"The saved data is " +
|
||||
describeType(saved) +
|
||||
", not the record AutistMask stores."
|
||||
);
|
||||
}
|
||||
|
||||
const version = versionProblem(saved);
|
||||
if (version) return version;
|
||||
|
||||
// Read once, from an OWN property or not at all, so that a polluted
|
||||
// prototype cannot decide whether a profile is refused. Note that
|
||||
// normalizePersisted() reads the same field plainly, and so WOULD consult
|
||||
// the prototype chain: the two halves agree only because a record arriving
|
||||
// from storage has been through structuredClone and always carries
|
||||
// Object.prototype. Nothing reachable from storage can put them at odds,
|
||||
// but a caller that hands either one a hand-built object with an unusual
|
||||
// prototype is not covered by that.
|
||||
const wallets =
|
||||
has(saved, "wallets") && saved.wallets !== undefined
|
||||
? saved.wallets
|
||||
: [];
|
||||
if (!Array.isArray(wallets)) {
|
||||
return (
|
||||
"The list of wallets in the saved data is " +
|
||||
describeType(wallets) +
|
||||
", not a list."
|
||||
);
|
||||
}
|
||||
for (let i = 0; i < wallets.length; i++) {
|
||||
const problem = walletProblem(wallets[i], i);
|
||||
if (problem) return problem;
|
||||
}
|
||||
|
||||
// networkId is not merely displayed: it is an object KEY into
|
||||
// state.networkEndpoints. A corrupt "__proto__" would set that map's
|
||||
// prototype instead of an own key, so the user's endpoint would silently
|
||||
// not be recorded and a switch away and back would return the public
|
||||
// default. isKnownNetworkId() is an own-property test against the network
|
||||
// table for exactly that reason.
|
||||
if (
|
||||
has(saved, "networkId") &&
|
||||
saved.networkId !== undefined &&
|
||||
!isKnownNetworkId(saved.networkId)
|
||||
) {
|
||||
return (
|
||||
"The saved data selects a network AutistMask does not know (" +
|
||||
JSON.stringify(saved.networkId) +
|
||||
")."
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a record this build cannot use.
|
||||
*
|
||||
* @param {*} saved the raw record from storage.
|
||||
* @throws {StateUnusableError}
|
||||
*/
|
||||
function assertStateUsable(saved) {
|
||||
const problem = stateProblem(saved);
|
||||
if (problem) throw new StateUnusableError(problem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `saved` is a usable record written before versions existed, and so
|
||||
* gets the current version stamped on it the next time anything writes. Purely
|
||||
* informational — the migration itself is that stamp, since version 1 IS the
|
||||
* unversioned shape.
|
||||
*
|
||||
* @param {*} saved
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function migrationNeeded(saved) {
|
||||
return (
|
||||
isPlainObject(saved) &&
|
||||
!has(saved, "schemaVersion") &&
|
||||
stateProblem(saved) === null
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
STATE_SCHEMA_VERSION,
|
||||
StateUnusableError,
|
||||
assertStateUsable,
|
||||
migrationNeeded,
|
||||
stateProblem,
|
||||
};
|
||||
Reference in New Issue
Block a user