pre-1.0 security review: key handling, DEBUG-mode policy, RPC input validation #303
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The repo's stated Next Step, and the last thing standing between milestone 1.0.0 and a tag. Milestone 1.0.0 is complete (61 closed, 0 open) and #190 is merge-ready, but no security review of the extension as a whole has been done — only the individual defects that were filed and fixed one at a time.
This is an audit task, not a code-change unit. Its deliverable is findings. Every finding gets its own issue with its own definition of done; this issue is closed when the audit is complete and its findings are filed, not when they are fixed.
Why it is worth doing as a whole rather than as its parts
Every unit that landed in 1.0.0 was reviewed against its own definition of done. Nothing has looked at the assembled system for defects that live between units — a guard that is correct in isolation but reachable around, an invariant two units each half-enforce, a path that only exists once several changes coexist. The approval machinery alone was reworked by #271, #275 and #280 in the same session.
Scope
Three lanes, as named in
TODO.md:DEBUGcannot be on in a shipped bundle, thatverify-buildcannot be satisfied by a bundle that did not come from this tree, and that no debug-only path leaks secrets or bypasses a gate when enabled.Rules for the audit
Definition of done
make checkuntouched and still green.Audit verdict: NO. Not deployable, do not tag 1.0.0.
Audited
next@ff3387din a separate clone. Baseline confirmed green first:make checkexit 0,make build4 bundles DEBUG-off,make test-e2e52/52,make test-e2e-firefox8/8. All browser work ran in the pinned containers, driving the real popup, the real background worker and real dApp round trips via throwaway probes built ontests/e2e/harness.jsandnetwork.js. Findings are reproduced, not read.Two of the blockers are silent, funds-destroying defects reachable in ordinary use with no attacker involved: a real dApp approval window silently deletes a wallet the user added while it was open (encrypted secret gone, no error, dApp transaction succeeds normally), and the wallet's own ERC-20 send signs a transfer amount it never displayed — the screen said
0.25 E2E, the signed artifact carried250000000000000000. There is also no release, no artifact, no packaging and no signing: on release Firefox this cannot be permanently installed at all.The signing and verification core is genuinely strong — the type allowlist, the canonical-bytes rebuild,
frompinning, and the password never leaving the popup all hold under probing. The failures are in the surfaces around it.Blockers (all in milestone 1.0.0)
saveState()is a last-writer-wins full-blob overwriteAmount 0.0000make buildcan produce an INSECURE debug bundle andverify-buildcertifies it green#190 is held
WIP:until these close.Non-blocker findings
Recorded here in full so none is lost; each will be filed as its own issue in the next round. None gates the tag on its own.
src/popup/index.js:203-209startsdoRefreshAndRender()and its 10s interval only whenstate.hasWalletwas already true at load. ObservedETH0.0000for 24s after creation, then correct on reopen. A funded new user sees0.0000and Send refuses.eth_sendTransactionon the page was still{"settled":"pending"}after 30s.src/content/index.js:69-74swallows thesendMessagerejection;src/content/inpage.js:88-97has no timeout. Same family as #262 and contradicts the #280 claim in PR 190's body.eth_signTypedData_v4never checksdomain.chainIdagainst the active network —src/background/index.js:801-834,src/shared/approvalVerify.js:563-593. A site connected on Sepolia can obtain a valid mainnet Permit2 signature. The chain id is on screen as one unlabelled domain line, with no contradiction flagged.toaddress —src/popup/views/approval.js:143,src/shared/uniswap.js:431-435. A hostile contract exposingexecute(bytes,bytes[],uint256)gets its calldata rendered as "Swap X to Y / Protocol: Uniswap Universal Router", with its own address under "Protocol".gasLimitandnonceare accepted —src/shared/approvalTx.js:54-66. SupplyinggasLimitdisables estimation, so an always-reverting transaction displays identically to a good one. Compat bug in the same place: dApps sendgas, notgasLimit, so a well-behaved dApp's limit is dropped while a hostile one's is honoured.#approve-tx-datahas nomax-height(src/popup/index.html:1526-1529) unlike#approve-sign-message(:1600-1604); a few hundred KB of calldata pushes the fee, nonce and Confirm button off-screen.MAX_GAS_LIMIT * MAX_FEE_PER_GAS= 10,000 ETH (src/shared/approvalVerify.js:134-139). A hostile RPC'sgetFeeData()passes the ceiling and is then "verified as matching what was displayed".src/shared/domainHash.js:29-35lowercases only,new URL().hostnamepreserves a trailing dot, andhostnameVariants(src/shared/phishingDomains.js:116-125) never yields the dotless form.https://evil.com./resolves normally and the red banner stays hidden. Uppercase and sub-subdomain evasions are correctly closed.eth_sendRawTransactionand 20 read methods are proxied to every origin with no connection check —src/background/index.js:637,841-848. Any page can use the wallet as an open relay and, ifstate.rpcUrlcarries an Alchemy/Infura key, consume it.AUTISTMASK_ACTIVE_CHANGEDis excluded fromPOPUP_ONLY_TYPES(src/background/index.js:1168-1176, handler at:1503) and rejects every pending approval. Not page-reachable today, but it is the one privileged handler outside the guard.log.infof/warnf/errorfare always on in a release build —src/shared/log.js:24. All 53 call sites checked; none passes a secret today. Latent hazard.debugModetoggle enablesdebugFetch(src/shared/log.js:46-53), which logs the user's addresses and RPC endpoint to the popup console. No secret, no gate bypassed. It is popup-only by accident (setRuntimeDebugis never called in the background) — worth a comment so nobody "fixes" it.src/popup/views/showPhrase.js:148,exportPrivkey.js:168. The views wipe the DOM meticulously, then hand the same secret to a clipboard with history.DEBUGcrosses a CommonJS boundary so esbuild cannot fold it). Verified unreachable —DEBUG:!1in every release bundle — but the guarantee rests on a module export's value, not on removed code.{"walletName":"Cold storage","addressCount":3,"trackedTokens":["E2E"],"allowedSites":{...},"utcTimestamps":true,"theme":"dark","rpcUrl":"http://127.0.0.1:8545"}became{"walletName":"Wallet 1","addressCount":1,"trackedTokens":[],"allowedSites":{},"utcTimestamps":false,"theme":"system","rpcUrl":"https://ethereum-rpc.publicnode.com"}. First address identical; addresses 2 and 3 re-derivable with "+"; everything else silently gone and the custom RPC reverted to a public node.build.js:149).dist/constants-bundles.txtanddist/styles.csssit atdist/root, outside both browser dirs.localStorage(src/shared/ens.js:18,32), outside the state blob and outside every clearing path. Privacy only.window.postMessageresponses use"*"atsrc/content/index.js:66,86wherelocation.originis used correctly at:44. Same window, so no cross-origin delivery; defence in depth.personal_signmessages (src/popup/views/approval.js:409,411).accessListis accepted, signed and verified but never displayed (src/shared/approvalTx.js:62,approvalVerify.js:308-313vsapproval.js:166-194) — the one attacker-controllable signed field that is invisible. Impact bounded to gas, which is displayed.personal_signrejects plain-UTF-8 payloads that other wallets accept (decodeHexMessagetogetBytesthrows).#btn-confirm-send: the popup's own Send to ConfirmTx to Sign to WaitTx path has no end-to-end coverage at all. That is how #305 survived.Checked and genuinely fine — do not re-cover
chrome.storage.sessionandchrome.storage.syncare both empty; onlystorage.localis used, and nostorage.syncreference exists insrc/.encryptWithPasswordreturns. Argon2id + XSalsa20-Poly1305 via libsodium, WASM backend enforced and loudly reported if lost.revealGenerationdiscards a decrypt landing after a view exit; no decrypted secret is held in any module-level variable;show-phraseandexport-privkeyare excluded fromRESTORABLE_VIEWS.eth_sendRawTransaction. WaitTx really is persisted across a popup close.eth_accountsfrom an unconnected origin correctly returns[].authorizationList(EIP-7702) stripped, forbidden, type-excluded and caught by the rebuild.fromownership is pinned at approval time and re-checked before signing.chainIdis verified against local state, never against anything the node said, so a lyingeth_chainIdcannot move the wallet.event.source === windowchecked in all three listeners, neither content script declaresall_frames, origin re-derived fromsender, noexternally_connectable.txValidation.js/holders.jsfail closed — unknown fee isFEE_UNAVAILABLEnever zero; unknown holder count is null, never "low".generateMnemoniccannot be reached by the runtime debug toggle, confirmed at bundle level.Not examined, and why
Vendored blocklist upstream content and pinning (build-time supply chain, hash-pinned by
script/vendor-blocklist); theethersand libsodium dependencies themselves; Firefox MV2 background timer semantics beyond the shared alarm path; unit-suite quality; performance and CSS.Unverified
getFeeData()are unproven. This is the human pass PR 190 already names; the probes do not replace it.keyverified directly, the storage-partition consequence inferred from documented Chrome behaviour. Covered by a DoD item on #310.gecko.idis stable, which is the right precondition, but no uninstall/reinstall cycle was driven. Given #310 this is the single most important unverified claim.AUTISTMASK_ACTIVE_CHANGEDhandler is reachable from a compromised renderer — no page-reachable path found; a compromised content script was not simulated.Audit complete, findings filed. Closing per this issue's own definition of done — it closes when the findings exist as issues, not when they are fixed.
Filed as blockers in milestone 1.0.0: #304, #305, #306, #307, #308, #309, #310, #311, #312.
Found subsequently, by reviewing the fixes rather than by the audit itself — which is the argument for adversarial review by someone who did not write the change: #316, #317, #320, #322, #324, #331, #333.
Non-blockers: #315, #318, #323, #325, #326, #328, #329, #332.
Closed so far: #305, #306, #307, #308, #309, #316, #317.
Answer to the question this issue asks — does anything found block the 1.0.0 tag? Yes. The verdict and the full evidence are in #303 (comment). The remaining blockers are the open items in milestone 1.0.0; #190 is held
WIP:until they close.Two coverage limits from that comment are restated here so they are not lost when this issue closes: nothing was tested against a real network with real funds — the RPC is stubbed in both harnesses, so nonce handling against a live mempool, real gas markets, reverts and reorgs remain unproven — and Firefox storage survival across an add-on remove/re-add was never driven, which given #310 is the single most important unverified claim about this extension.