1 Commits
Author SHA1 Message Date
sneak 4ba2e69599 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
2026-10-04 17:57:57 +00:00
6 changed files with 29 additions and 74 deletions
+3 -5
View File
@@ -1929,11 +1929,9 @@ view would leave a wallet one click from deletion.
- From: color dot + full address + etherscan link
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
decode to as UTF-8, laid out left to right in the order of the bytes that
are signed, right-to-left characters included. Each control character,
each line or paragraph separator (U+2028, U+2029; left in the text, a
paragraph separator would end that layout for the text after it), and each
character that paints nothing (format characters such as zero-width and
bidirectional ones, default-ignorable characters such as variation
are signed, right-to-left characters included. Each control character, and
each character that paints nothing (format characters such as zero-width
and bidirectional ones, default-ignorable characters such as variation
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
mark instead of acting on the text; a line feed is shown as a line break.
Bytes that are not UTF-8 are shown as "This message is not text." For
+4 -4
View File
@@ -51,10 +51,10 @@ but the review is broader than any of them.
acting on it, so a site could make the message read differently from what is
signed, and a message that was not hex was shown as NUL characters. The hex is
now shown as "Raw data" alongside the decoded text, the text is laid out left
to right in byte order, control characters, line and paragraph separators and
characters that paint nothing are shown as `U+XXXX` marks, and a message that
is not hex by the rule signing reads it with is shown as plain text with
"Sign" disabled, since such a message has no bytes to sign.
to right in byte order, control characters and characters that paint nothing
are shown as `U+XXXX` marks, and a message that is not hex by the rule signing
reads it with is shown as plain text with "Sign" disabled, since such a
message has no bytes to sign.
- 2026-10-04: A site has at most one connection prompt and one signature prompt
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
+1 -2
View File
@@ -68,8 +68,7 @@ body {
/* A personal message on the signature screen is laid out left to right in
* the order of its bytes. Without this, right-to-left characters in it move
* the characters around them: `5`, U+05C3, `00` would read as `500`
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
* the text after it, so src/popup/views/approval.js shows one as a mark. */
* followed by U+05C3. */
.am-byte-order {
direction: ltr;
unicode-bidi: bidi-override;
+3 -8
View File
@@ -410,17 +410,12 @@ function codePointMark(c) {
// The text as HTML, with each character that paints nothing (zero-width and
// bidirectional characters, variation selectors and Hangul fillers among
// them), each control character and each line or paragraph separator
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
// Left in the text, a paragraph separator would end the byte-order layout
// for everything after it. The marks are plain ASCII, so the second pass
// leaves them be.
// 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}\p{Zl}\p{Zp}]/gu, (c) =>
c === "\n" ? "<br>" : codePointMark(c),
);
.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
+15 -32
View File
@@ -3233,18 +3233,10 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
});
// A right-to-left character must not move the characters around it: U+05C3
// between "5" and "00" would otherwise put "500" on screen before it. A
// paragraph separator (U+2029) before it, left in the text, would end the
// byte-order layout and bring that back
// between "5" and "00" would otherwise put "500" on screen before it
// (https://git.eeqj.de/sneak/AutistMask/issues/403).
test("a personal message is laid out in the order of its bytes (#403)", async (env) => {
const rightToLeft = String.fromCodePoint(0x05c3);
const text =
"Sign in" +
String.fromCodePoint(0x2029) +
"Pay 5" +
rightToLeft +
"00 ETH";
const text = "Pay 5" + String.fromCodePoint(0x05c3) + "00 ETH";
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
hexlify(toUtf8Bytes(text)),
env.expectedAddress,
@@ -3252,24 +3244,19 @@ test("a personal message is laid out in the order of its bytes (#403)", async (e
const popup = await waitForApprovalWindow(env.ctx);
await visible(popup, "#view-approve-sign");
// The text on screen, marks included, and the left edge of each of its
// characters, in byte order. A character the browser's fonts draw with
// no width shares its neighbour's edge.
const shown = await popup.evaluate(() => {
// The left edge of each character on screen, in byte order. A character
// the browser's fonts draw with no width shares its neighbour's edge.
const lefts = await popup.evaluate(() => {
const message = document.getElementById("approve-sign-message");
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
const textNode = message.firstChild;
const range = document.createRange();
let text = "";
const lefts = [];
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
for (let i = 0; i < node.length; i++) {
range.setStart(node, i);
range.setEnd(node, i + 1);
lefts.push(range.getBoundingClientRect().left);
const out = [];
for (let i = 0; i < textNode.length; i++) {
range.setStart(textNode, i);
range.setEnd(textNode, i + 1);
out.push(range.getBoundingClientRect().left);
}
text += node.data;
}
return { text, lefts };
return out;
});
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
@@ -3279,14 +3266,10 @@ test("a personal message is laid out in the order of its bytes (#403)", async (e
);
assert(
shown.text === "Sign inU+2029Pay 5" + rightToLeft + "00 ETH",
"the paragraph separator is not shown as a mark: " +
JSON.stringify(shown.text),
);
assert(
shown.lefts.every((left, i) => i === 0 || left >= shown.lefts[i - 1]),
lefts.length === text.length &&
lefts.every((left, i) => i === 0 || left >= lefts[i - 1]),
"the personal message is not laid out in byte order: " +
JSON.stringify(shown.lefts),
JSON.stringify(lefts),
);
});
+3 -23
View File
@@ -1,9 +1,8 @@
// The signature prompt shows a personal message as the bytes that are signed
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
// text it decodes to with control characters, line and paragraph separators
// and characters that paint nothing marked rather than obeyed, laid out in
// byte order, and a message that is not hex as plain text that cannot be
// signed.
// text it decodes to with control characters and characters that paint
// nothing marked rather than obeyed, laid out in byte order, and a message
// that is not hex as plain text that cannot be signed.
//
// Driven against a minimal DOM stub in the shape
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
@@ -26,8 +25,6 @@ const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
const HANGUL_FILLER = String.fromCodePoint(0x3164);
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
function makeElement(id) {
const classes = new Set();
@@ -156,23 +153,6 @@ test("the message is laid out in byte order", async () => {
).toBe(true);
});
test("a control character other than a line feed is marked", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
expect(shownMessage()).toBe("aU+0000bU+0009c");
});
test("line and paragraph separators are marked", async () => {
// Left in the text, a paragraph separator would end the byte-order
// layout for everything after it.
await openPersonalSign(
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
);
const html = node("approve-sign-message").innerHTML;
expect(html).not.toContain(LINE_SEPARATOR);
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
expect(shownMessage()).toBe("aU+2028bU+2029");
});
test("a line feed is shown as a line break", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");