A persisted container was checked while its ENTRIES were dereferenced
unchecked. A stored `{"0x…": "notalist"}` in allowedSites passes the state
gate, renders a working popup, and then throws inside saveState()'s per-
hostname merge, so every save from that moment on fails while the UI looks
entirely healthy. deniedSites has the identical shape; fraudContracts is the
same class with a milder consequence.
The sweep for that class found four more:
- selectedToken, dereferenced as text behind a truthiness-only restore gate.
- rpcUrl, handed whole to `new JsonRpcProvider()` by getProvider(), which
throws SYNCHRONOUSLY for a non-string — from txStatus.js and addWallet.js,
neither inside a try, and the first reachable from a stored
`currentView: "wait-tx"` through the unguarded restoreView().
- The ENTRIES of viewData. Four restore branches gate on one truthy field and
hand the rest to a renderer that calls address.toLowerCase(): a stored
`{"currentView":"success-tx","viewData":{"hash":"0x1"}}` throws out of
restoreView(), skipping the rest of popup init.
- selectedWallet / selectedAddress. `wallets` is a real Array, so a stored
"map", "length", "constructor" or "__proto__" is TRUTHY: hasValidAddress()'s
`&&` does not short-circuit and `.addresses[…]` throws. A stale INTEGER index
is the safe case.
Floors, in src/shared/persistedState.js: allowedSites/deniedSites through
siteMap(), fraudContracts and each hostname list through textList(),
selectedToken and activeAddress as text-or-null, rpcUrl and blockscoutUrl as
non-empty text, selectedWallet and selectedAddress as a non-negative integer
or null, and each networkEndpoints pair's two URL fields — which
applyChainSwitchFields() assigns straight onto s.rpcUrl on the next switch.
Guards, in src/popup/viewRouter.js: the four restore branches that gate on one
truthy field now check the entries their renderer dereferences, as
txStatus.restoreWait() has always done for wait-tx. "confirm-tx" joins
ADDRESS_VIEWS, because its Sign button dereferences
state.wallets[state.selectedWallet] behind no guard of its own.
A stored own "__proto__" key is dropped by siteMap(): it can never be a wallet
address, so it grants nothing, and keeping it only keeps a value the next save
would hand to the prototype setter. networkEndpoints keeps unknown keys by
design, so mergeMapByKey() in src/shared/state.js now writes with
defineProperty as well — the guard in the floor was being undone one layer
downstream.
A save that fails is also told, not merely repaired: onSaveFailure() reports
every failed save, awaited or not (the save queue's own rejection handler is
what made a failure vanish), and the popup raises a persistent "NOT SAVED"
banner naming the reason. doRefreshAndRender() no longer rejects, since every
one of its call sites fires it and walks away.
The per-field justification in the header of src/shared/stateSchema.js is
replaced by tests/persistedFieldContract.test.js. That comment shipped a false
claim in three consecutive changes; the artifact was the problem. The test is
one row per persisted field, declaring 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, the real JsonRpcProvider constructor for rpcUrl, and — for every field
whose only defence is that nothing dereferences it structurally — a boot of the
real popup entry point over that value onto EVERY view the popup can reopen
onto.
That last part is what makes the claim falsifiable, and it is why this defect
class is worth a harness at all: it lives on the RESTORE path and not on Home.
So the suite goes red whenever one of those boots reaches a structural
dereference on the view it restored onto — including one that takes TWO
corrupted fields at once, because the verdict is the combined boot itself and
the per-field re-boot that names a culprit can only decorate the message.
Every swept field is driven at both polarities, or proven unable to be falsy
after the floor: a value nothing in src/ writes is a wrong-typed one and
therefore truthy, so without a falsy slot a dereference behind `if (!state.x)`
is never reached on the very boot that corrupts x, and for three of these
fields the falsy answer is the DEFAULT_STATE default — the branch every
ordinary install takes. It goes red too 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.
What it does NOT drive, stated accurately rather than claiming total coverage:
every combination. Four value combinations per view are driven, not the product
of the twelve swept fields. The last of the four is itself a MIX rather than a
uniform polarity — every falsy-capable field is falsy on it while the ones that
cannot be falsy stay hostile-truthy — so many two-field interactions are driven
and fatal; one needing a pairing none of the four produces is not driven at
all. Nor is anything no stored record reaches by itself: a view only forward
navigation opens, and anything behind a click. The header and the README mirror
now point at the test instead of restating it.
The boots are cheap enough to keep by construction rather than by sampling.
Every field the router itself reads is driven onto each view individually,
since a hostile value in one of those legitimately changes which view renders;
every other unfloored field is corrupted on the SAME boot, and that boot has to
land on the view it stored — so a field that does move the routing cannot hide
in the crowd, and the failure path re-boots one field at a time to name a
culprit without ever being able to clear the failure. That is forty-four boots
instead of several hundred; the suite runs in 12.8s against a 30s cap.
The polarity guard counts only values driven onto EVERY restorable view. A
hostileRestore entry may carry `views: [...]`, and counting one would let a
future row satisfy the guard with a polarity that reaches a single renderer.
No current row does; this keeps it that way.
The DOM stub in tests/support/popupBoot.js gained one thing to make any of that
possible: an element's parentElement. Without it success-tx and transaction
threw on the first line that hides a field's wrapper, so neither renderer could
be booted onto at all — every boot aimed at them fell back to Home instead, and
the base profile the sweep starts from is now asserted to render each view
rather than fall back, so that cannot go unnoticed again.
368 lines
15 KiB
JavaScript
368 lines
15 KiB
JavaScript
// A stored profile the popup cannot read must produce a SCREEN, not a blank
|
|
// popup (https://git.eeqj.de/sneak/AutistMask/issues/311).
|
|
//
|
|
// The three corrupt blobs below are the ones the pre-1.0 audit wrote into
|
|
// storage. Against the build this file was added to, each one rendered nothing
|
|
// at all — no view, no message, no control — because init() dereferenced
|
|
// `state.wallets[0].addresses` on a record nothing had validated and threw
|
|
// before the first showView().
|
|
//
|
|
// So the assertions here are deliberately made through the REAL popup entry
|
|
// point rather than against the recovery view module directly. A recovery
|
|
// screen that renders perfectly when something calls it, and that nothing
|
|
// calls, is exactly the defect: what has to be true is that BOOTING the popup
|
|
// on a bad blob lands on it.
|
|
//
|
|
// The boot harness and its DOM stub — built FROM src/popup/index.html, so
|
|
// "which views are visible" is answered against the real element set — live in
|
|
// tests/support/popupBoot.js, since tests/persistedEntryFloors.test.js needs
|
|
// the same boot.
|
|
//
|
|
// The fourth case is the upgrade one, and it is the case that must NOT reach
|
|
// the recovery screen: every install in the field has a valid profile with no
|
|
// version field, and showing those users a wipe prompt would be a worse defect
|
|
// than the one being fixed. It is migrated in place and keeps working.
|
|
|
|
const {
|
|
bootPopup,
|
|
cleanupPopup,
|
|
unversionedValidProfile,
|
|
ADDRESS,
|
|
TOKEN_ADDRESS,
|
|
} = require("./support/popupBoot");
|
|
|
|
// ------------------------------------------------------------- fixtures
|
|
|
|
// The three blobs from the issue, each with the error it produced.
|
|
const CORRUPT_BLOBS = [
|
|
{
|
|
name: "wallets is a string",
|
|
blob: { hasWallet: true, wallets: ADDRESS, activeAddress: ADDRESS },
|
|
},
|
|
{
|
|
name: "wallets is an array of garbage",
|
|
blob: {
|
|
hasWallet: true,
|
|
wallets: [null, 42, "wallet"],
|
|
activeAddress: ADDRESS,
|
|
},
|
|
},
|
|
{
|
|
name: "future-schema blob (unknown fields, no version)",
|
|
blob: {
|
|
hasWallet: true,
|
|
// A later schema that renamed the field and moved the key
|
|
// material, written by a build this one knows nothing about, and
|
|
// stamped with no version because this build never wrote one.
|
|
wallets: [
|
|
{
|
|
id: "wallet-1",
|
|
label: "Wallet 1",
|
|
accounts: [{ addr: ADDRESS, wei: "0x0" }],
|
|
keyring: { kind: "hd", vault: "…" },
|
|
},
|
|
],
|
|
profileFormat: "am-2",
|
|
activeAccount: ADDRESS,
|
|
},
|
|
},
|
|
];
|
|
|
|
afterEach(cleanupPopup);
|
|
|
|
// --------------------------------------------------------------- tests
|
|
|
|
describe("a stored profile the popup cannot read", () => {
|
|
for (const { name, blob } of CORRUPT_BLOBS) {
|
|
test(`${name}: the recovery screen, not a blank popup`, async () => {
|
|
const env = await bootPopup(blob);
|
|
|
|
// Asserted together, and in the audit's own shape: a failure here
|
|
// prints both what was on screen and what the console said, which
|
|
// is the pair that identifies this defect.
|
|
expect({
|
|
visibleViews: env.visibleViews(),
|
|
errors: env.pageErrors,
|
|
}).toEqual({ visibleViews: ["state-recovery"], errors: [] });
|
|
|
|
// The screen has to NAME the problem. A blank recovery screen is
|
|
// the same dead end with a border around it.
|
|
expect(env.text("state-recovery-problem").length).toBeGreaterThan(
|
|
10,
|
|
);
|
|
});
|
|
}
|
|
|
|
test("the Settings gear is hidden, since every screen behind it reads the profile", async () => {
|
|
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
|
|
|
expect(env.hidden("btn-settings")).toBe(true);
|
|
});
|
|
|
|
test("it does not write over the record it could not read", async () => {
|
|
// The blob is evidence, and possibly the only copy of key material in
|
|
// a shape a later build could recover. A boot that normalized it back
|
|
// into storage would destroy exactly that.
|
|
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
|
|
|
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[2].blob);
|
|
expect(env.storage.set).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("the export on the recovery screen", () => {
|
|
test("hands back the raw stored record verbatim", async () => {
|
|
const env = await bootPopup(CORRUPT_BLOBS[2].blob);
|
|
|
|
await env.click("btn-state-recovery-export");
|
|
|
|
// Shown in the page, which always works, whatever the browser does
|
|
// with a download from an extension popup.
|
|
expect(env.hidden("state-recovery-blob")).toBe(false);
|
|
expect(JSON.parse(env.value("state-recovery-blob"))).toEqual(
|
|
CORRUPT_BLOBS[2].blob,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("the destructive reset on the recovery screen", () => {
|
|
test("erases nothing without the typed confirmation", async () => {
|
|
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
|
|
|
env.node("state-recovery-reset-input").value = "yes";
|
|
await env.click("btn-state-recovery-reset");
|
|
|
|
expect(env.storage.read("autistmask")).toEqual(CORRUPT_BLOBS[0].blob);
|
|
expect(env.text("state-recovery-flash").length).toBeGreaterThan(10);
|
|
expect(env.reloaded()).toBe(0);
|
|
});
|
|
|
|
test("erases the stored profile once the phrase is typed", async () => {
|
|
const env = await bootPopup(CORRUPT_BLOBS[0].blob);
|
|
|
|
env.node("state-recovery-reset-input").value = "erase my wallet";
|
|
await env.click("btn-state-recovery-reset");
|
|
|
|
expect(env.storage.read("autistmask")).toBeUndefined();
|
|
expect(env.reloaded()).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("an unversioned profile that is perfectly valid", () => {
|
|
// The upgrade case. Every install in the field is in this state, and the
|
|
// popup must load it, not offer to wipe it.
|
|
test("boots to the wallet list, not the recovery screen", async () => {
|
|
const env = await bootPopup(unversionedValidProfile());
|
|
|
|
expect(env.visibleViews()).toEqual(["main"]);
|
|
expect(env.pageErrors).toEqual([]);
|
|
});
|
|
|
|
test("is migrated in place: the version is stamped, the wallet survives", async () => {
|
|
const env = await bootPopup(unversionedValidProfile());
|
|
|
|
const stored = env.storage.read("autistmask");
|
|
expect(stored.schemaVersion).toBe(1);
|
|
expect(stored.wallets).toHaveLength(1);
|
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
|
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
|
expect(stored.activeAddress).toBe(ADDRESS);
|
|
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
|
});
|
|
});
|
|
|
|
describe("a first run with nothing in storage", () => {
|
|
test("boots to Welcome", async () => {
|
|
const env = await bootPopup(undefined);
|
|
|
|
expect(env.visibleViews()).toEqual(["welcome"]);
|
|
expect(env.pageErrors).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("a garbage value in a field the gate does not check", () => {
|
|
// The gate refuses only what nothing can floor: the wallet list, the
|
|
// version, the network key. Everything else is normalizePersisted()'s job,
|
|
// and where that job was written as `saved.x || default` rather than a
|
|
// type check, a TRUTHY value of the wrong type walked straight through and
|
|
// threw on the first dereference — the same blank popup this issue is
|
|
// about, measured the same way. Every row below did, at the head named
|
|
// against it; none has ever been removed from this list.
|
|
//
|
|
// These belong on the floor rather than in the gate: none of these values
|
|
// carries key material, all have a sane default, and sending a user whose
|
|
// wallets are perfectly readable to an export-or-erase screen over a
|
|
// broken token list would destroy more than it saves.
|
|
//
|
|
// The CONTAINER and its ELEMENTS are separate defects. Round 2 floored the
|
|
// containers with Array.isArray(), which left every row whose container is
|
|
// a well-formed list of malformed entries still blanking the popup: the
|
|
// dereference is `t.address.toLowerCase()`, one level below the check.
|
|
const CORRUPT_FIELDS = [
|
|
// Container shapes. Observed at 2e2ecf9, before the round-2 floor:
|
|
// trackedTokens: "nope" -> views=[] "Cannot read properties of
|
|
// undefined (reading 'toLowerCase')"
|
|
// trackedTokens: 42 -> views=[] "trackedTokens is not iterable"
|
|
// trackedTokens: {a:1} -> views=[] "trackedTokens is not iterable"
|
|
// activeAddress: 42 -> views=[] "address.slice is not a
|
|
// function"
|
|
// activeAddress: {a:1} -> views=[] "address.slice is not a
|
|
// function"
|
|
{ name: "trackedTokens is a string", patch: { trackedTokens: "nope" } },
|
|
{ name: "trackedTokens is a number", patch: { trackedTokens: 42 } },
|
|
{
|
|
name: "trackedTokens is an object",
|
|
patch: { trackedTokens: { a: 1 } },
|
|
},
|
|
{ name: "activeAddress is a number", patch: { activeAddress: 42 } },
|
|
{
|
|
name: "activeAddress is an object",
|
|
patch: { activeAddress: { a: 1 } },
|
|
},
|
|
// Element shapes: a list, holding entries that are not token records.
|
|
// Observed at a10a984, AFTER the container floor:
|
|
// [1,2] -> views=[] "Cannot read properties of undefined
|
|
// (reading 'toLowerCase')"
|
|
// [null] -> views=[] "Cannot read properties of null
|
|
// (reading 'address')"
|
|
// [{}] -> views=[] "Cannot read properties of undefined
|
|
// (reading 'toLowerCase')"
|
|
// [{address:42}] -> views=[] "t.address.toLowerCase is not a
|
|
// function"
|
|
// ["0xAA…"] -> views=[] "Cannot read properties of undefined
|
|
// (reading 'toLowerCase')"
|
|
{
|
|
name: "trackedTokens holds numbers",
|
|
patch: { trackedTokens: [1, 2] },
|
|
},
|
|
{
|
|
name: "trackedTokens holds null",
|
|
patch: { trackedTokens: [null] },
|
|
},
|
|
{
|
|
name: "trackedTokens holds a record with no address",
|
|
patch: { trackedTokens: [{}] },
|
|
},
|
|
{
|
|
name: "trackedTokens holds a record whose address is a number",
|
|
patch: { trackedTokens: [{ address: 42 }] },
|
|
},
|
|
{
|
|
name: "trackedTokens holds bare address strings",
|
|
patch: { trackedTokens: [TOKEN_ADDRESS] },
|
|
},
|
|
];
|
|
|
|
// The same defect one level deeper, inside a wallet the gate accepted.
|
|
// tokenBalances is written WHOLESALE by refreshBalances(), so the partial
|
|
// write https://git.eeqj.de/sneak/AutistMask/issues/311 names as the live
|
|
// cause of a corrupt record lands exactly here. Observed at a10a984:
|
|
// "x" -> views=[] "Cannot read properties of undefined (reading
|
|
// 'toLowerCase')" (a string iterates as characters)
|
|
// [null] -> views=[] "Cannot read properties of null (reading
|
|
// 'balance')"
|
|
// [42] -> views=[] "Cannot read properties of undefined (reading
|
|
// 'toLowerCase')"
|
|
// 42 -> views=[] "number 42 is not iterable"
|
|
const CORRUPT_TOKEN_BALANCES = [
|
|
{ name: "a string", value: "x" },
|
|
{ name: "a list holding null", value: [null] },
|
|
{ name: "a list of numbers", value: [42] },
|
|
{ name: "a number", value: 42 },
|
|
];
|
|
|
|
function profileWithTokenBalances(value) {
|
|
const profile = unversionedValidProfile();
|
|
profile.wallets[0].addresses[0].tokenBalances = value;
|
|
return profile;
|
|
}
|
|
|
|
for (const { name, patch } of CORRUPT_FIELDS) {
|
|
test(`${name}: a working popup, not a blank one`, async () => {
|
|
const env = await bootPopup(
|
|
Object.assign(unversionedValidProfile(), patch),
|
|
);
|
|
|
|
expect({
|
|
visibleViews: env.visibleViews(),
|
|
errors: env.pageErrors,
|
|
}).toEqual({ visibleViews: ["main"], errors: [] });
|
|
});
|
|
}
|
|
|
|
for (const { name, value } of CORRUPT_TOKEN_BALANCES) {
|
|
test(`an address whose tokenBalances is ${name}: a working popup, not a blank one`, async () => {
|
|
const env = await bootPopup(profileWithTokenBalances(value));
|
|
|
|
expect({
|
|
visibleViews: env.visibleViews(),
|
|
errors: env.pageErrors,
|
|
}).toEqual({ visibleViews: ["main"], errors: [] });
|
|
});
|
|
}
|
|
|
|
test("the wallet is intact afterwards, and the bad value is gone", async () => {
|
|
const env = await bootPopup(
|
|
Object.assign(unversionedValidProfile(), {
|
|
trackedTokens: "nope",
|
|
activeAddress: 42,
|
|
}),
|
|
);
|
|
|
|
const stored = env.storage.read("autistmask");
|
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
|
expect(stored.trackedTokens).toEqual([]);
|
|
// Floored to null, then filled in by init()'s auto-default.
|
|
expect(stored.activeAddress).toBe(ADDRESS);
|
|
});
|
|
|
|
test("an empty activeAddress does not leave the popup with none selected", async () => {
|
|
// "" is text, so a type check alone lets it through — and init()
|
|
// auto-selects the first address only on a STRICT null, so it has to
|
|
// be floored to null rather than kept.
|
|
const env = await bootPopup(
|
|
Object.assign(unversionedValidProfile(), { activeAddress: "" }),
|
|
);
|
|
|
|
expect(env.visibleViews()).toEqual(["main"]);
|
|
expect(env.storage.read("autistmask").activeAddress).toBe(ADDRESS);
|
|
});
|
|
|
|
test("a malformed token entry is dropped, and the well-formed ones beside it survive", async () => {
|
|
const env = await bootPopup(
|
|
Object.assign(unversionedValidProfile(), {
|
|
trackedTokens: [
|
|
1,
|
|
null,
|
|
{},
|
|
{ address: 42 },
|
|
TOKEN_ADDRESS,
|
|
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
|
|
],
|
|
}),
|
|
);
|
|
|
|
const stored = env.storage.read("autistmask");
|
|
expect(stored.trackedTokens).toEqual([
|
|
{ address: TOKEN_ADDRESS, symbol: "AAA", decimals: 18 },
|
|
]);
|
|
});
|
|
|
|
test("a malformed tokenBalances entry is dropped, and the wallet and its address survive", async () => {
|
|
const env = await bootPopup(
|
|
profileWithTokenBalances([
|
|
null,
|
|
42,
|
|
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
|
|
]),
|
|
);
|
|
|
|
const stored = env.storage.read("autistmask");
|
|
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
|
|
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
|
expect(stored.wallets[0].addresses[0].tokenBalances).toEqual([
|
|
{ address: TOKEN_ADDRESS, symbol: "AAA", balance: "2.0" },
|
|
]);
|
|
});
|
|
});
|