The token screen's decimals and holder count, the ETH price, every address total and each balance row's USD value went into innerHTML unescaped, against the rule at the top of src/popup/views/helpers.js. 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, not UTF-16 units, so the cut never leaves half of an emoji, which rendered as U+FFFD. explorerLink() was already removed on next. Model: opus-5-5
122 lines
4.8 KiB
JavaScript
122 lines
4.8 KiB
JavaScript
// The escape every view depends on, and the length bound on a displayed
|
|
// token symbol. Both were added for #307, where a token whose symbol()
|
|
// returned an <iframe> tag rendered that iframe inside the popup.
|
|
|
|
const { escapeHtml } = require("../src/shared/html");
|
|
const {
|
|
displaySymbol,
|
|
MAX_SYMBOL_LENGTH,
|
|
UNKNOWN_SYMBOL,
|
|
} = require("../src/shared/symbolDisplay");
|
|
|
|
// The payload from the issue's reproduction, verbatim.
|
|
const HOSTILE_SYMBOL =
|
|
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
|
|
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
|
|
|
|
describe("escapeHtml", () => {
|
|
test("escapes all five characters, quotes included", () => {
|
|
expect(escapeHtml("&<>\"'")).toBe("&<>"'");
|
|
});
|
|
|
|
// The regression this function was rewritten for. The previous
|
|
// implementation round-tripped through a detached div's textContent,
|
|
// and an HTML text node serializes a quote as itself — so a value with
|
|
// a quote in it broke straight out of data-copy="..." and href="...".
|
|
test("escapes quotes, which the textContent round trip did not", () => {
|
|
expect(escapeHtml('a"b')).toBe("a"b");
|
|
expect(escapeHtml("a'b")).toBe("a'b");
|
|
});
|
|
|
|
test("does not double-escape an ampersand it just introduced", () => {
|
|
expect(escapeHtml("<")).toBe("&lt;");
|
|
expect(escapeHtml("&")).toBe("&amp;");
|
|
});
|
|
|
|
test("leaves a string with nothing to escape untouched", () => {
|
|
expect(escapeHtml("USDC")).toBe("USDC");
|
|
expect(escapeHtml("")).toBe("");
|
|
});
|
|
|
|
test("renders the hostile symbol inert", () => {
|
|
const out = escapeHtml(HOSTILE_SYMBOL);
|
|
expect(out).not.toContain("<");
|
|
expect(out).not.toContain(">");
|
|
expect(out).not.toContain('"');
|
|
expect(out).toContain("<iframe");
|
|
});
|
|
|
|
// A quoted attribute is broken out of by a quote, a bare one by a
|
|
// space; both are closed here. Asserted as a whole attribute rather
|
|
// than character by character, because it is the attribute that has to
|
|
// survive, not the escape table.
|
|
test("a value carrying a quote stays inside its attribute", () => {
|
|
const evil = '" onload="alert(1)';
|
|
const attr = `data-copy="${escapeHtml(evil)}"`;
|
|
expect(attr).toBe('data-copy="" onload="alert(1)"');
|
|
expect(attr.split('"').length - 1).toBe(2);
|
|
});
|
|
|
|
test("null and undefined render as nothing rather than as words", () => {
|
|
expect(escapeHtml(null)).toBe("");
|
|
expect(escapeHtml(undefined)).toBe("");
|
|
});
|
|
|
|
test("coerces a non-string without losing the escape", () => {
|
|
expect(escapeHtml(42)).toBe("42");
|
|
expect(escapeHtml({ toString: () => "<b>" })).toBe("<b>");
|
|
});
|
|
});
|
|
|
|
describe("displaySymbol", () => {
|
|
test("passes every symbol in the bundled list through unchanged", () => {
|
|
const { TOKENS } = require("../src/shared/tokenList");
|
|
for (const t of TOKENS) {
|
|
expect([t.address, displaySymbol(t.symbol)]).toEqual([
|
|
t.address,
|
|
t.symbol,
|
|
]);
|
|
}
|
|
});
|
|
|
|
test("caps an over-long symbol and marks it as truncated", () => {
|
|
const long = "A".repeat(4096);
|
|
const out = displaySymbol(long);
|
|
expect(out.length).toBe(MAX_SYMBOL_LENGTH);
|
|
expect(out.endsWith("…")).toBe(true);
|
|
});
|
|
|
|
test("keeps a symbol of exactly the cap intact", () => {
|
|
const exact = "A".repeat(MAX_SYMBOL_LENGTH);
|
|
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);
|
|
expect(displaySymbol(undefined)).toBe(UNKNOWN_SYMBOL);
|
|
});
|
|
|
|
// The cap is a layout bound and nothing more: it must not be mistaken
|
|
// for the thing that makes a symbol safe to render. A short hostile
|
|
// symbol passes through it untouched, and is inert only because the
|
|
// caller escapes it afterwards.
|
|
test("does not sanitize — a short markup symbol survives it verbatim", () => {
|
|
expect(displaySymbol("<img src=x>")).toBe("<img src=x>");
|
|
expect(escapeHtml(displaySymbol("<img src=x>"))).toBe(
|
|
"<img src=x>",
|
|
);
|
|
});
|
|
});
|