Compare commits

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