harden: show a personal message's hex and its text in byte order, hidden characters marked (closes #403)
check / check (push) Failing after 3s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 3s

The signature screen showed only the text a personal message decodes
to, with bidirectional, right-to-left and zero-width characters acting
on it, so a site could make the message read differently from the
bytes that are signed, and a message that was not hex was decoded into
NUL characters. The screen now shows the hex as "Raw data" alongside
the text, lays the text out left to right in byte order, and shows
each control character and each character that paints nothing (the set
src/shared/symbolSpoof.js already strips) as a U+XXXX mark. A message
is hex when getBytes, which signing uses, reads it; one that is not
cannot be signed, so it is shown as plain text with "Sign" disabled.

Model: opus-5-5
This commit is contained in:
2026-10-04 17:57:57 +00:00
parent d1751beb32
commit 4ba2e69599
8 changed files with 343 additions and 16 deletions
+52 -11
View File
@@ -26,6 +26,7 @@ const {
} = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const {
resolveTokenDecimals,
resolveTokenSymbol,
@@ -380,20 +381,43 @@ function showTxApproval(details) {
);
}
// Whether a personal message is hex by the rule signing reads it with:
// signing takes getBytes(message), which throws on anything else.
function isHexMessage(message) {
try {
getBytes(message);
return true;
} catch {
return false;
}
}
// The text the hex message's bytes decode to as UTF-8, or null when they are
// not UTF-8. The caller has checked that the message is hex.
function decodeHexMessage(hex) {
try {
const bytes = Uint8Array.from(
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
return toUtf8String(getBytes(hex));
} catch {
return null;
}
}
// A character shown as a bordered U+XXXX mark.
function codePointMark(c) {
const code = c.codePointAt(0).toString(16).toUpperCase();
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
}
// The text as HTML, with each character that paints nothing (zero-width and
// bidirectional characters, variation selectors and Hangul fillers among
// them) and each control character shown as a mark. A line feed is shown as
// a line break. The marks are plain ASCII, so the second pass leaves them be.
function markInvisibleCharacters(text) {
return escapeHtml(text)
.replace(INVISIBLE_CHARACTERS, codePointMark)
.replace(/\p{Cc}/gu, (c) => (c === "\n" ? "<br>" : codePointMark(c)));
}
// The type ethers will sign typed data as. ethers does not read the page's
// `primaryType`: it takes the one struct in `types` that no other struct
// refers to. Throws when the types name no such single struct, which ethers
@@ -657,15 +681,33 @@ function showSignApproval(details) {
? "Typed data (EIP-712)"
: "Personal message";
// A personal message is signed as the bytes its hex encodes, so the hex
// is shown as well as any text it decodes to, and that text is laid out
// left to right in the order of its bytes. Signing reads the bytes from
// the hex, so a message that is not hex cannot be signed: it is shown as
// the text it is, and refused.
let refusal = null;
$("approve-sign-hex-section").classList.add("hidden");
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
if (isTyped) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else {
refusal = typedDataRefusal(sp);
} else if (isHexMessage(sp.message)) {
const decoded = decodeHexMessage(sp.message);
if (decoded !== null) {
$("approve-sign-message").textContent = decoded;
$("approve-sign-message").innerHTML =
markInvisibleCharacters(decoded);
} else {
$("approve-sign-message").textContent = sp.message;
$("approve-sign-message").textContent = "This message is not text.";
}
$("approve-sign-hex").textContent = sp.message;
$("approve-sign-hex-section").classList.remove("hidden");
} else {
$("approve-sign-message").innerHTML = markInvisibleCharacters(
sp.message,
);
refusal =
"This message is plain text, not hex, so it cannot be signed.";
}
// Display danger warning for eth_sign (raw hash signing)
@@ -687,7 +729,6 @@ function showSignApproval(details) {
showView("approve-sign");
attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) {
showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true;