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.
448 lines
16 KiB
JavaScript
448 lines
16 KiB
JavaScript
const {
|
|
$,
|
|
showView,
|
|
showFlash,
|
|
balanceLinesForAddress,
|
|
isoDate,
|
|
timeAgo,
|
|
addressDotHtml,
|
|
addressTitle,
|
|
escapeHtml,
|
|
displaySymbol,
|
|
truncateMiddle,
|
|
renderAddressHtml,
|
|
attachCopyHandlers,
|
|
pushCurrentView,
|
|
} = require("./helpers");
|
|
const { state, saveState, currentAddress } = require("../../shared/state");
|
|
const { notify } = require("../../shared/browserApi");
|
|
const {
|
|
updateSendBalance,
|
|
renderSendTokenSelect,
|
|
resetSendValidation,
|
|
} = require("./send");
|
|
const { deriveAddressFromXpub } = require("../../shared/wallet");
|
|
const { canRemoveAddress } = require("../../shared/walletDelete");
|
|
const {
|
|
walletDefect,
|
|
walletDefectHtml,
|
|
} = require("../../shared/walletDefects");
|
|
const {
|
|
formatUsd,
|
|
formatAddressTotal,
|
|
getPrice,
|
|
getAddressValue,
|
|
} = require("../../shared/prices");
|
|
const {
|
|
fetchRecentTransactions,
|
|
filterTransactions,
|
|
} = require("../../shared/transactions");
|
|
const { log } = require("../../shared/log");
|
|
|
|
function findActiveAddr() {
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
if (a.address === state.activeAddress) return a;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function renderTotalValue() {
|
|
const el = $("total-value");
|
|
const subEl = $("total-value-sub");
|
|
const priceEl = $("eth-price-display");
|
|
if (!el) return;
|
|
|
|
const ethPrice = getPrice("ETH");
|
|
if (priceEl) {
|
|
priceEl.innerHTML = ethPrice
|
|
? formatUsd(ethPrice) + " USD/ETH"
|
|
: " ";
|
|
}
|
|
|
|
const addr = findActiveAddr();
|
|
if (!addr) {
|
|
el.innerHTML = " ";
|
|
if (subEl) subEl.innerHTML = " ";
|
|
return;
|
|
}
|
|
const ethBal = parseFloat(addr.balance || "0");
|
|
const ethStr = ethBal.toFixed(4) + " ETH";
|
|
const ethUsd = ethPrice ? " (" + formatUsd(ethBal * ethPrice) + ")" : "";
|
|
el.textContent = ethStr + ethUsd;
|
|
|
|
if (subEl) {
|
|
subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
|
|
}
|
|
}
|
|
|
|
function renderActiveAddress() {
|
|
const el = $("active-address-display");
|
|
if (!el) return;
|
|
if (state.activeAddress) {
|
|
el.innerHTML = renderAddressHtml(state.activeAddress);
|
|
attachCopyHandlers(el);
|
|
} else {
|
|
el.textContent = "";
|
|
}
|
|
}
|
|
|
|
let homeTxs = [];
|
|
|
|
function renderHomeTxList(ctx) {
|
|
const list = $("home-tx-list");
|
|
if (!list) return;
|
|
if (homeTxs.length === 0) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">No transactions found.</div>';
|
|
return;
|
|
}
|
|
let html = "";
|
|
let i = 0;
|
|
for (const tx of homeTxs) {
|
|
// For swap transactions, show the user's own labelled wallet
|
|
// address (the one that initiated the swap) instead of the
|
|
// contract address which is not useful in the list view.
|
|
const counterparty =
|
|
tx.direction === "contract" && tx.directionLabel === "Swap"
|
|
? tx.from
|
|
: tx.direction === "sent" || tx.direction === "contract"
|
|
? tx.to
|
|
: tx.from;
|
|
// directionLabel is the explorer's own method name for a contract
|
|
// call, title-cased — attacker-chosen for an attacker's contract.
|
|
const dirLabel = escapeHtml(tx.directionLabel);
|
|
const sym = displaySymbol(tx.symbol);
|
|
const amountStr = tx.value
|
|
? escapeHtml(tx.value + " " + sym)
|
|
: escapeHtml(sym);
|
|
const title = addressTitle(counterparty, state.wallets);
|
|
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
|
|
const displayAddr = title || truncateMiddle(counterparty, maxAddr);
|
|
const addrStr = escapeHtml(displayAddr);
|
|
const dot = addressDotHtml(counterparty);
|
|
const err = tx.isError ? " (failed)" : "";
|
|
const opacity = tx.isError ? " opacity:0.5;" : "";
|
|
const ago = escapeHtml(timeAgo(tx.timestamp));
|
|
const iso = escapeHtml(isoDate(tx.timestamp));
|
|
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
|
|
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
|
|
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${addrStr}</span><span>${amountStr}</span></div>`;
|
|
html += `</div>`;
|
|
i++;
|
|
}
|
|
list.innerHTML = html;
|
|
list.querySelectorAll(".home-tx-row").forEach((row) => {
|
|
row.addEventListener("click", () => {
|
|
const idx = parseInt(row.dataset.tx, 10);
|
|
const tx = homeTxs[idx];
|
|
// Set selectedWallet/selectedAddress so back navigation works
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (
|
|
let ai = 0;
|
|
ai < state.wallets[wi].addresses.length;
|
|
ai++
|
|
) {
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (
|
|
addr.toLowerCase() === tx.from.toLowerCase() ||
|
|
addr.toLowerCase() === tx.to.toLowerCase()
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
state.selectedToken = null;
|
|
ctx.showTransactionDetail(tx);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
async function loadHomeTxs(ctx) {
|
|
const allAddresses = [];
|
|
for (const w of state.wallets) {
|
|
for (const a of w.addresses) {
|
|
allAddresses.push(a.address);
|
|
}
|
|
}
|
|
if (allAddresses.length === 0) return;
|
|
|
|
const filters = {
|
|
hideSpoofedSymbols: state.hideSpoofedSymbols,
|
|
hideLowHolderTokens: state.hideLowHolderTokens,
|
|
hideFraudContracts: state.hideFraudContracts,
|
|
hideDustTransactions: state.hideDustTransactions,
|
|
dustThresholdGwei: state.dustThresholdGwei,
|
|
fraudContracts: state.fraudContracts,
|
|
};
|
|
|
|
try {
|
|
const fetches = allAddresses.map((addr) =>
|
|
fetchRecentTransactions(addr, state.blockscoutUrl),
|
|
);
|
|
const results = await Promise.all(fetches);
|
|
|
|
// Merge, deduplicate by hash, filter, sort, take 25
|
|
const seen = new Set();
|
|
let merged = [];
|
|
for (const txs of results) {
|
|
for (const tx of txs) {
|
|
if (seen.has(tx.hash)) continue;
|
|
seen.add(tx.hash);
|
|
merged.push(tx);
|
|
}
|
|
}
|
|
|
|
const filtered = filterTransactions(merged, filters);
|
|
|
|
// Persist any newly discovered fraud contracts
|
|
if (filtered.newFraudContracts.length > 0) {
|
|
for (const addr of filtered.newFraudContracts) {
|
|
if (!state.fraudContracts.includes(addr)) {
|
|
state.fraudContracts.push(addr);
|
|
}
|
|
}
|
|
await saveState();
|
|
}
|
|
|
|
merged = filtered.transactions;
|
|
merged.sort((a, b) => b.blockNumber - a.blockNumber);
|
|
homeTxs = merged.slice(0, 25);
|
|
renderHomeTxList(ctx);
|
|
} catch (e) {
|
|
log.errorf("loadHomeTxs failed:", e.message);
|
|
const list = $("home-tx-list");
|
|
if (list) {
|
|
list.innerHTML =
|
|
'<div class="text-muted text-xs py-1">Failed to load transactions.</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// The wallet list markup. Pure: it reads state and returns a string, so the
|
|
// list can be asserted on without a DOM.
|
|
function walletListHtml() {
|
|
let html = "";
|
|
state.wallets.forEach((wallet, wi) => {
|
|
const defect = walletDefect(wallet);
|
|
html += `<div>`;
|
|
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
|
|
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`;
|
|
// No "+" on a defective wallet: deriving another address from that
|
|
// xpub would only add one more address the key does not produce
|
|
// under the standard path.
|
|
if (!defect && (wallet.type === "hd" || wallet.type === "xprv")) {
|
|
html += `<button class="btn-add-address border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-wallet="${wi}" title="Add another address to this wallet">+</button>`;
|
|
}
|
|
html += `</div>`;
|
|
html += walletDefectHtml(wallet);
|
|
|
|
wallet.addresses.forEach((addr, ai) => {
|
|
html += `<div class="address-row py-1 border-b border-border-light cursor-pointer hover:bg-hover" data-wallet="${wi}" data-address="${ai}">`;
|
|
const isActive = state.activeAddress === addr.address;
|
|
const infoBtn = `<span class="btn-addr-info text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg" style="padding:0" data-wallet="${wi}" data-address="${ai}">[info]</span>`;
|
|
// Only where a wallet can spare the address: a wallet holding a
|
|
// single address has no remove control, because its last address
|
|
// is never removable.
|
|
const removeBtn = canRemoveAddress(wallet)
|
|
? `<span class="btn-remove-address text-xs cursor-pointer border border-border hover:bg-fg hover:text-bg ml-1" style="padding:0" data-wallet="${wi}" data-address="${ai}" title="Remove this address from the wallet">[x]</span>`
|
|
: "";
|
|
const dot = addressDotHtml(addr.address);
|
|
const titleBold = isActive ? "font-bold" : "";
|
|
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
|
|
if (addr.ensName) {
|
|
// An ENS reverse record is whatever the name owner set it
|
|
// to; renderAddressHtml() escapes its own copy of this and
|
|
// this list was the one that did not.
|
|
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
|
|
}
|
|
html += `<div class="flex text-xs items-center justify-between">`;
|
|
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`;
|
|
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
|
|
html += `</div>`;
|
|
const addrTotal = formatAddressTotal(getAddressValue(addr));
|
|
html += `<div class="text-xs text-muted text-right min-h-[1rem]">${addrTotal || " "}</div>`;
|
|
html += balanceLinesForAddress(
|
|
addr,
|
|
state.trackedTokens,
|
|
state.showZeroBalanceTokens,
|
|
);
|
|
html += `</div>`;
|
|
});
|
|
|
|
html += `</div>`;
|
|
});
|
|
return html;
|
|
}
|
|
|
|
function render(ctx) {
|
|
const container = $("wallet-list");
|
|
if (state.wallets.length === 0) {
|
|
container.innerHTML =
|
|
'<p class="text-muted py-2">No wallets yet. Add one to get started.</p>';
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = walletListHtml();
|
|
|
|
container.querySelectorAll(".address-row").forEach((row) => {
|
|
row.addEventListener("click", async () => {
|
|
const wi = parseInt(row.dataset.wallet, 10);
|
|
const ai = parseInt(row.dataset.address, 10);
|
|
const addr = state.wallets[wi].addresses[ai].address;
|
|
if (state.activeAddress !== addr) {
|
|
state.activeAddress = addr;
|
|
await saveState();
|
|
render(ctx);
|
|
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
|
|
}
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-addr-info").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
state.selectedWallet = parseInt(btn.dataset.wallet, 10);
|
|
state.selectedAddress = parseInt(btn.dataset.address, 10);
|
|
ctx.showAddressDetail();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-remove-address").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
ctx.showDeleteAddress(
|
|
parseInt(btn.dataset.wallet, 10),
|
|
parseInt(btn.dataset.address, 10),
|
|
);
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".btn-add-address").forEach((btn) => {
|
|
btn.addEventListener("click", async (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(btn.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const newAddr = deriveAddressFromXpub(
|
|
wallet.xpub,
|
|
wallet.nextIndex,
|
|
);
|
|
wallet.addresses.push({
|
|
address: newAddr,
|
|
balance: "0.0000",
|
|
tokenBalances: [],
|
|
});
|
|
wallet.nextIndex++;
|
|
await saveState();
|
|
render(ctx);
|
|
ctx.doRefreshAndRender();
|
|
});
|
|
});
|
|
|
|
container.querySelectorAll(".wallet-name").forEach((span) => {
|
|
span.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
const wi = parseInt(span.dataset.wallet, 10);
|
|
const wallet = state.wallets[wi];
|
|
const input = document.createElement("input");
|
|
input.type = "text";
|
|
input.value = wallet.name;
|
|
input.className =
|
|
"font-bold border border-border p-0 bg-bg text-fg";
|
|
input.style.width = "100%";
|
|
const save = async () => {
|
|
const val = input.value.trim();
|
|
if (val && val !== wallet.name) {
|
|
wallet.name = val;
|
|
await saveState();
|
|
}
|
|
render(ctx);
|
|
};
|
|
input.addEventListener("blur", save);
|
|
input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") input.blur();
|
|
if (ev.key === "Escape") {
|
|
input.value = wallet.name;
|
|
input.blur();
|
|
}
|
|
});
|
|
span.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
});
|
|
});
|
|
|
|
renderTotalValue();
|
|
renderActiveAddress();
|
|
loadHomeTxs(ctx);
|
|
}
|
|
|
|
// The defect of the wallet the selected address belongs to, or null. Call
|
|
// after selectActiveAddress().
|
|
function selectedWalletDefect() {
|
|
if (state.selectedWallet === null) return null;
|
|
return walletDefect(state.wallets[state.selectedWallet]);
|
|
}
|
|
|
|
function selectActiveAddress() {
|
|
for (let wi = 0; wi < state.wallets.length; wi++) {
|
|
for (let ai = 0; ai < state.wallets[wi].addresses.length; ai++) {
|
|
if (
|
|
state.wallets[wi].addresses[ai].address === state.activeAddress
|
|
) {
|
|
state.selectedWallet = wi;
|
|
state.selectedAddress = ai;
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function init(ctx) {
|
|
$("btn-add-wallet-bottom").addEventListener("click", ctx.showAddWalletView);
|
|
|
|
$("btn-main-send").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
// Before the balance check and before any password is asked for: this
|
|
// wallet cannot sign at all, so the send screen is a dead end.
|
|
const defect = selectedWalletDefect();
|
|
if (defect) {
|
|
showFlash(defect.shortMessage);
|
|
return;
|
|
}
|
|
const addr = currentAddress();
|
|
if (!addr.balance || parseFloat(addr.balance) === 0) {
|
|
showFlash("Cannot send \u2014 zero balance.");
|
|
return;
|
|
}
|
|
$("send-to").value = "";
|
|
$("send-amount").value = "";
|
|
$("send-token").classList.remove("hidden");
|
|
$("send-token-static").classList.add("hidden");
|
|
renderSendTokenSelect(addr);
|
|
updateSendBalance();
|
|
resetSendValidation();
|
|
pushCurrentView();
|
|
showView("send");
|
|
});
|
|
|
|
$("btn-main-receive").addEventListener("click", () => {
|
|
if (!selectActiveAddress()) {
|
|
showFlash("No active address selected.");
|
|
return;
|
|
}
|
|
ctx.showReceive();
|
|
});
|
|
}
|
|
|
|
module.exports = { init, render, walletListHtml };
|