A token's symbol is whatever its symbol() returns, the block explorer passes it through unfiltered, and balanceLine() interpolated it into an innerHTML string. A token with the 1,000 holders the spam filter asks for, airdropped to the victim, could therefore paint a full-viewport cross-origin iframe over the wallet's own UI, on the screens where the user is used to typing their password. escapeHtml moves to the new src/shared/html.js as a pure string replace over &, <, >, " and '. The implementation it replaces round-tripped through a detached element's textContent, which escapes neither quote character, and it was already in use inside data-copy="..." and would have been inside href="...". Being pure also makes it testable without a DOM shim. Every interpolation into an innerHTML string across src/popup/views/ was audited rather than only the reported one. Also unescaped: the transaction lists' direction label (the explorer's method name, attacker-chosen for an attacker's contract), the wallet name and ENS name in the Home wallet list, the URL in the explorer link's href, the blockie data: URI, and the confirmation screen's warning line, which carries only fixed strings today but is one wiring change from carrying scraped explorer text. Explorer URLs are now built by one helper that percent-encodes the path segment, so a from/to out of explorer JSON cannot re-point the link. Where a value is a markup fragment this code just built, or a loop index, or a locally computed number, it stays bare; the rule and the reason are stated at the top of helpers.js. Both manifests now declare default-src 'self' with frame-src 'none'. Four directives had to stay looser than 'self' and none of them generalises: style-src needs 'unsafe-inline' because the popup sets presentation through style="..." attributes and Firefox has never implemented style-src-attr; img-src needs data: for the blockies; connect-src needs https: and http: because the RPC endpoint is user-configurable and a local node over http://127.0.0.1 is a supported configuration. frame-src, form-action and base-uri are named rather than inherited, because the last two do not fall back to default-src at all. tests/manifest.test.js now pins the whole directive set exactly, in both directions, and README.md carries the reasoning. Displayed symbols are capped at 12 characters, the bound lookupTokenInfo() already applied to a symbol read straight off a contract; the explorer path had none. The cap is a layout bound and is documented as not being the security control. isSpoofedSymbol() is untouched: it answers whether a symbol collides with a known ticker, which is a different question, and repurposing it here would have been the wrong control. Verified failing first, four ways. Restricting escapeHtml to & < > (the escape the old textContent round trip actually performed) fails 5 unit tests including the data-copy attribute break-out. Removing the length cap fails 3. Dropping default-src from manifest/chrome.json fails 2. Removing both the escape and the cap and running the full Chrome suite fails the new browser test with the attack reproduced: an <iframe id="pwn"> in the popup DOM, intercepting pointer events over the Back button.
178 lines
6.5 KiB
JavaScript
178 lines
6.5 KiB
JavaScript
// Confirmation screen for removing one address from a wallet that derives
|
|
// its addresses from an extended key.
|
|
//
|
|
// No password is asked for, unlike delete-wallet. A password gates the
|
|
// disclosure or destruction of a secret, and this does neither: the address
|
|
// is derived from key material the wallet still holds, so removing it only
|
|
// stops the wallet tracking it. An explicit confirmation screen is the
|
|
// proportionate treatment.
|
|
|
|
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
escapeHtml,
|
|
goBack,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
addressHoldsFunds,
|
|
balanceLinesForAddress,
|
|
} = require("./helpers");
|
|
const { formatAddressTotal, getAddressValue } = require("../../shared/prices");
|
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
|
const { state, saveState } = require("../../shared/state");
|
|
const {
|
|
canRemoveAddress,
|
|
removeAddressFromState,
|
|
broadcastActiveChanged,
|
|
} = require("../../shared/walletDelete");
|
|
|
|
// The wallet and address indices this screen is confirming, or null when it
|
|
// is not confirming anything.
|
|
let target = null;
|
|
let ctx = null;
|
|
|
|
function setFlash(msg) {
|
|
const el = $("delete-address-flash");
|
|
el.textContent = msg;
|
|
el.style.visibility = msg ? "visible" : "hidden";
|
|
}
|
|
|
|
// What it actually takes to get the address back, which is not what the
|
|
// screen used to claim.
|
|
//
|
|
// Neither obvious route works: "+" derives the next unused index, because
|
|
// wallet.nextIndex is a high-water mark and is deliberately not rewound; and
|
|
// re-importing this wallet's key material is refused as a duplicate by
|
|
// findWalletByXpub() for as long as the wallet is here. What remains is to
|
|
// delete the whole wallet in Settings — which asks for the password and
|
|
// destroys the stored secret — and import again, after which
|
|
// scanForAddresses() rediscovers the address only if it has on-chain
|
|
// activity. An address that was never used is not found by that scan, and
|
|
// the copy must not imply otherwise.
|
|
//
|
|
// The noun follows the wallet: an xprv wallet holds no recovery phrase, and
|
|
// this screen is offered on xprv wallets too.
|
|
function recoveryPathText(wallet) {
|
|
const secret = walletHasRecoveryPhrase(wallet)
|
|
? "recovery phrase"
|
|
: "extended private key";
|
|
return (
|
|
"Getting the address back into this list is not easy, so be sure. " +
|
|
"Adding an address derives the next unused one, not this one, and " +
|
|
"importing this " +
|
|
secret +
|
|
" again is refused while this wallet is still here. The way back is " +
|
|
"to delete the whole wallet in Settings, which asks for your " +
|
|
"password and destroys the stored " +
|
|
secret +
|
|
", and then import that " +
|
|
secret +
|
|
" again. The scan that follows only finds addresses that have " +
|
|
"on-chain activity, so an address that has never been used is not " +
|
|
"found by it."
|
|
);
|
|
}
|
|
|
|
// The balance warning, or a blank line when the address holds nothing.
|
|
//
|
|
// A balance is a reason to be careful, not a reason to refuse: the funds are
|
|
// at the address, not in this list, and stay there either way.
|
|
//
|
|
// "Holds" means ETH or any ERC-20 the wallet knows about — an address with no
|
|
// ETH and a five-figure stablecoin position must not get the blank line on
|
|
// the one screen whose job is to warn. The sentence names no figure of its
|
|
// own: the rendered lines round to four decimals, so a sentence built from a
|
|
// rounded number would report "0.0000 ETH" for an address holding real money.
|
|
// The lines below it carry the amounts, in the same format as Home and
|
|
// AddressDetail, followed by the USD total when there is one to give — no
|
|
// total line at all on testnet or before the first price fetch, and no figure
|
|
// when every holding here is one with no price, since "$0.00" directly under
|
|
// "This address holds a balance." is a contradiction.
|
|
function balanceWarningHtml(addr) {
|
|
if (!addressHoldsFunds(addr)) return " ";
|
|
const line = formatAddressTotal(getAddressValue(addr));
|
|
const total = line
|
|
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>`
|
|
: "";
|
|
return (
|
|
`<p class="mb-1">This address holds a balance. Removing it does not ` +
|
|
`move or spend anything; the balance stays at the address.</p>` +
|
|
balanceLinesForAddress(addr, state.trackedTokens, false) +
|
|
total
|
|
);
|
|
}
|
|
|
|
function show(walletIdx, addrIdx) {
|
|
const wallet = state.wallets[walletIdx];
|
|
const addr = wallet && wallet.addresses[addrIdx];
|
|
if (!addr) return;
|
|
target = { walletIdx, addrIdx };
|
|
|
|
$("delete-address-label").textContent = "Address " + (addrIdx + 1);
|
|
$("delete-address-wallet-name").textContent =
|
|
wallet.name || "Wallet " + (walletIdx + 1);
|
|
|
|
const value = $("delete-address-value");
|
|
value.innerHTML = renderAddressHtml(addr.address, {
|
|
ensName: addr.ensName,
|
|
});
|
|
attachCopyHandlers(value);
|
|
|
|
$("delete-address-recovery").textContent = recoveryPathText(wallet);
|
|
$("delete-address-balance").innerHTML = balanceWarningHtml(addr);
|
|
|
|
setFlash("");
|
|
showView("delete-address-confirm");
|
|
}
|
|
|
|
function init(_ctx) {
|
|
ctx = _ctx;
|
|
|
|
$("btn-delete-address-back").addEventListener("click", () => {
|
|
target = null;
|
|
goBack();
|
|
});
|
|
|
|
$("btn-delete-address-confirm").addEventListener("click", async () => {
|
|
if (target === null) {
|
|
setFlash("No address is selected for removal.");
|
|
return;
|
|
}
|
|
|
|
const { walletIdx, addrIdx } = target;
|
|
if (!canRemoveAddress(state.wallets[walletIdx])) {
|
|
setFlash(
|
|
"This address cannot be removed, because a wallet always " +
|
|
"keeps at least one address.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const { removed, activeAddressChanged } = removeAddressFromState(
|
|
state,
|
|
walletIdx,
|
|
addrIdx,
|
|
);
|
|
if (!removed) {
|
|
setFlash("This address could not be removed.");
|
|
return;
|
|
}
|
|
|
|
target = null;
|
|
// Save before broadcasting: the background reads the active address
|
|
// back out of storage to build accountsChanged.
|
|
await saveState();
|
|
if (activeAddressChanged) broadcastActiveChanged();
|
|
|
|
ctx.renderWalletList();
|
|
goBack();
|
|
showFlash("Address removed.");
|
|
});
|
|
}
|
|
|
|
// recoveryPathText and balanceWarningHtml are exported so the two pieces of
|
|
// copy that carry the screen's substance can be tested without a DOM; show()
|
|
// is a one-line assignment for each.
|
|
module.exports = { init, show, recoveryPathText, balanceWarningHtml };
|