diff --git a/TODO.md b/TODO.md
index 5b8e533..14e3f1b 100644
--- a/TODO.md
+++ b/TODO.md
@@ -45,6 +45,16 @@ but the review is broader than any of them.
# Completed Steps
+- 2026-10-05: Escaping in the popup's views follows its own rule with no
+ exceptions ([#329](https://git.eeqj.de/sneak/AutistMask/issues/329)). The
+ decimals and holder count on a token's screen, and every USD figure (the ETH
+ price, each total and each balance row's value), went into `innerHTML`
+ unescaped; they are escaped now. None could carry markup, but `formatUsd()`
+ writes a value under a cent as `< $0.01`. `displaySymbol()` counts a symbol in
+ code points rather than UTF-16 units, so a cut never splits an emoji into a
+ half that renders as U+FFFD. `explorerLink()`, also named in the issue, was
+ already removed by [#168](https://git.eeqj.de/sneak/AutistMask/issues/168).
+
- 2026-10-05: Chrome draws the popup in its monospace font
([#418](https://git.eeqj.de/sneak/AutistMask/issues/418)), as Firefox does.
Chrome adds a stylesheet of its own to extension pages that sets the font on
diff --git a/src/popup/views/addressDetail.js b/src/popup/views/addressDetail.js
index 39f1f6e..feeadde 100644
--- a/src/popup/views/addressDetail.js
+++ b/src/popup/views/addressDetail.js
@@ -66,7 +66,7 @@ function show() {
$("address-line").dataset.full = addr.address;
attachCopyHandlers($("address-line"));
const usdTotal = formatAddressTotal(getAddressValue(addr));
- $("address-usd-total").innerHTML = usdTotal || " ";
+ $("address-usd-total").innerHTML = escapeHtml(usdTotal) || " ";
const ensEl = $("address-ens");
// ENS is now shown inside renderAddressHtml, hide the separate element
ensEl.classList.add("hidden");
diff --git a/src/popup/views/addressToken.js b/src/popup/views/addressToken.js
index 451e58b..e834e7f 100644
--- a/src/popup/views/addressToken.js
+++ b/src/popup/views/addressToken.js
@@ -103,7 +103,7 @@ function show() {
// USD total for this token only
const usdVal = price && amount !== null ? amount * price : null;
const usdStr = formatUsd(usdVal);
- $("address-token-usd-total").innerHTML = usdStr || " ";
+ $("address-token-usd-total").innerHTML = escapeHtml(usdStr) || " ";
// Single token balance line (no tokenId — not clickable here)
$("address-token-balance").innerHTML = balanceLine(symbol, amount, price);
@@ -148,9 +148,9 @@ function show() {
if (tokenSymbol)
infoHtml += `
Symbol: ${tokenSymbol}
`;
if (tokenDecimals != null)
- infoHtml += `Decimals: ${tokenDecimals}
`;
+ infoHtml += `Decimals: ${escapeHtml(tokenDecimals)}
`;
if (tokenHolders != null)
- infoHtml += `Holders: ${Number(tokenHolders).toLocaleString()}
`;
+ infoHtml += `Holders: ${escapeHtml(Number(tokenHolders).toLocaleString())}
`;
if (projectUrl)
infoHtml += ``;
contractInfo.innerHTML = infoHtml;
diff --git a/src/popup/views/helpers.js b/src/popup/views/helpers.js
index bf44d49..e01272a 100644
--- a/src/popup/views/helpers.js
+++ b/src/popup/views/helpers.js
@@ -325,7 +325,7 @@ function balanceLine(symbol, amount, price, tokenId) {
const qty = amount === null ? "quantity unknown" : amount.toFixed(4);
const usd =
price && amount !== null
- ? formatUsd(amount * price) || " "
+ ? escapeHtml(formatUsd(amount * price)) || " "
: " ";
// tokenId is a contract address out of the same explorer JSON, and it
// lands inside a quoted attribute.
diff --git a/src/popup/views/home.js b/src/popup/views/home.js
index 192a5fc..37046f9 100644
--- a/src/popup/views/home.js
+++ b/src/popup/views/home.js
@@ -63,7 +63,7 @@ function renderTotalValue() {
const ethPrice = getPrice("ETH");
if (priceEl) {
priceEl.innerHTML = ethPrice
- ? formatUsd(ethPrice) + " USD/ETH"
+ ? escapeHtml(formatUsd(ethPrice) + " USD/ETH")
: " ";
}
@@ -79,7 +79,8 @@ function renderTotalValue() {
el.textContent = ethStr + ethUsd;
if (subEl) {
- subEl.innerHTML = formatAddressTotal(getAddressValue(addr)) || " ";
+ subEl.innerHTML =
+ escapeHtml(formatAddressTotal(getAddressValue(addr))) || " ";
}
}
@@ -280,7 +281,7 @@ function walletListHtml() {
}
html += `${escapeHtml(addr.address)}
`;
const addrTotal = formatAddressTotal(getAddressValue(addr));
- html += `${addrTotal || " "}
`;
+ html += `${escapeHtml(addrTotal) || " "}
`;
html += balanceLinesForAddress(
addr,
state.trackedTokens,
diff --git a/src/shared/symbolDisplay.js b/src/shared/symbolDisplay.js
index e2f7ba6..bf412da 100644
--- a/src/shared/symbolDisplay.js
+++ b/src/shared/symbolDisplay.js
@@ -20,6 +20,10 @@
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it.
+//
+// Characters are counted as code points, not UTF-16 units, so an emoji is
+// one character and the cut never falls between the two halves of one: a
+// half on its own renders as U+FFFD.
const MAX_SYMBOL_LENGTH = 12;
@@ -32,8 +36,9 @@ const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL;
- if (s.length <= MAX_SYMBOL_LENGTH) return s;
- return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
+ const chars = Array.from(s);
+ if (chars.length <= MAX_SYMBOL_LENGTH) return s;
+ return chars.slice(0, MAX_SYMBOL_LENGTH - 1).join("") + "…";
}
module.exports = {
diff --git a/tests/addressValue.test.js b/tests/addressValue.test.js
index 3d6851d..bd0a462 100644
--- a/tests/addressValue.test.js
+++ b/tests/addressValue.test.js
@@ -194,6 +194,14 @@ describe("the wallet list on Home", () => {
clearPrices();
expect(walletListTotal(FULLY_PRICED)).toBe(" ");
});
+
+ // A total under a cent is written "< $0.01", and the "<" is escaped
+ // here as the removal warning escapes it.
+ test("a total under a cent is escaped, as on the removal warning", () => {
+ const tiny = { ...EMPTY, balance: "0.000001" };
+ expect(walletListTotal(tiny)).toBe("Total: < $0.01");
+ expect(removalWarningTotal(tiny)).toBe("Total: < $0.01");
+ });
});
describe("the balance warning on the address-removal confirmation", () => {
diff --git a/tests/balanceLineEscaping.test.js b/tests/balanceLineEscaping.test.js
index 6acfa69..e1bacf1 100644
--- a/tests/balanceLineEscaping.test.js
+++ b/tests/balanceLineEscaping.test.js
@@ -66,4 +66,11 @@ describe("balanceLine", () => {
expect(html).toContain("1.5000");
expect(html).toContain('data-token="0xabc"');
});
+
+ // formatUsd() writes a value under a cent as "< $0.01".
+ test("escapes the USD value along with the symbol", () => {
+ const html = balanceLine("USDC", 0.001, 1, null);
+ expect(html).toContain("< $0.01");
+ expect(html).not.toContain("< $0.01");
+ });
});
diff --git a/tests/htmlEscape.test.js b/tests/htmlEscape.test.js
index 69fe008..df0cf46 100644
--- a/tests/htmlEscape.test.js
+++ b/tests/htmlEscape.test.js
@@ -91,6 +91,17 @@ describe("displaySymbol", () => {
expect(displaySymbol(exact)).toBe(exact);
});
+ // An emoji outside the Basic Multilingual Plane is two UTF-16 units.
+ // Cutting between them leaves half of one, which renders as U+FFFD.
+ test("counts an emoji as one character and never cuts one in half", () => {
+ expect(displaySymbol("🚀".repeat(MAX_SYMBOL_LENGTH))).toBe(
+ "🚀".repeat(MAX_SYMBOL_LENGTH),
+ );
+ expect(displaySymbol("🚀".repeat(20))).toBe(
+ "🚀".repeat(MAX_SYMBOL_LENGTH - 1) + "…",
+ );
+ });
+
test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);