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.
This commit was merged in pull request #366.
This commit is contained in:
+144
-29
@@ -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
@@ -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
@@ -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()'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(). 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 values — the 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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user