Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 7b99d421b8 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 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 19:00:59 +00:00
20 changed files with 421 additions and 403 deletions
+21 -15
View File
@@ -1927,10 +1927,19 @@ view would leave a wallet one click from deletion.
- Danger warning box (shown for `eth_sign`, which signs a raw hash) - Danger warning box (shown for `eth_sign`, which signs a raw hash)
- Type: "Personal message" or "Typed data (EIP-712)" - Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link - From: color dot + full address + etherscan link
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/ - Message: for `personal_sign` and `eth_sign`, the text the message's bytes
message fields (EIP-712 typed data). The primary type shown is the one decode to as UTF-8, laid out left to right in the order of the bytes that
ethers signs, derived from the typed data's `types`, not the type the site are signed, right-to-left characters included. Each control character,
states. 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
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
typed data, formatted domain/type/message fields (EIP-712). The primary
type shown is the one ethers signs, derived from the typed data's `types`,
not the type the site states.
- Token permission warning, at the top of the message (typed data whose - Token permission warning, at the top of the message (typed data whose
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
@@ -1942,12 +1951,20 @@ view would leave a wallet one click from deletion.
domain's `verifyingContract`; any those fields do not give is shown as domain's `verifyingContract`; any those fields do not give is shown as
`Unknown`, and the domain, type and message lines still follow. Only typed `Unknown`, and the domain, type and message lines still follow. Only typed
data that cannot be read at all is shown as raw text. data that cannot be read at all is shown as raw text.
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
the site sent it. The bytes it encodes are what is signed, as an EIP-191
personal message.
- Password input and an error line - Password input and an error line
- "Sign" / "Reject" buttons - "Sign" / "Reject" buttons
- **Transitions**: - **Transitions**:
- Typed data that states no primary type, or one other than the type it - Typed data that states no primary type, or one other than the type it
would be signed as, or that cannot be read → shown with the error line would be signed as, or that cannot be read → shown with the error line
saying so and "Sign" disabled; only "Reject" remains saying so and "Sign" disabled; only "Reject" remains
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
an even number of hex digits, the form ethers' `getBytes` reads when
signing) → shown as plain text, with the error line "This message is plain
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
the bytes from the hex, so such a message has none to sign
- "Sign" (correct password) → signs locally → closes popup (returns - "Sign" (correct password) → signs locally → closes popup (returns
signature) signature)
- "Sign" (wrong password, or a signing failure) → error line, no screen - "Sign" (wrong password, or a signing failure) → error line, no screen
@@ -2168,17 +2185,6 @@ the log level and turns the banner on, and that is all it may ever do: it feeds
constant directly, so no runtime toggle in a release build can reach the constant directly, so no runtime toggle in a release build can reach the
hardcoded test phrase. hardcoded test phrase.
At the raised log level the console also shows the wallet's addresses with their
balances and ENS names, the token contracts looked up, and a line for each
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
for its response. A request is logged by its HTTP method, the origin of its URL
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
refresh, the token lookup and a failed endpoint check in settings name the
endpoint by its origin too. The URL's path and query string, where RPC providers
put API keys, any user name and password in it, and the request body are never
logged.
### Key Decisions ### Key Decisions
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small - **No framework**: The popup UI is vanilla JS and HTML. The extension is small
+10 -12
View File
@@ -45,18 +45,16 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-10-04: Debug mode no longer writes RPC API keys to the console - 2026-10-04: The signature screen shows a personal message as the bytes that
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
every request's full URL and body, so an RPC endpoint with a key in its path showed only the decoded text, with bidirectional and zero-width characters
or query string printed that key on every request. It now logs the HTTP acting on it, so a site could make the message read differently from what is
method, the URL's origin and, for a JSON-RPC call, the method name. The signed, and a message that was not hex was shown as NUL characters. The hex is
balance refresh and token lookup log the RPC endpoint by its origin too. A now shown as "Raw data" alongside the decoded text, the text is laid out left
failed RPC call's error line prints the error's short message, which names the to right in byte order, control characters, line and paragraph separators and
HTTP status, not its full message, which carries the request URL. A failed characters that paint nothing are shown as `U+XXXX` marks, and a message that
endpoint check in settings names the endpoint by its origin, not the `fetch` is not hex by the rule signing reads it with is shown as plain text with
error's message, which carries the whole URL, password included, for a URL "Sign" disabled, since such a message has no bytes to sign.
with a user name and password. The README's DEBUG Mode Policy says what debug
mode logs.
- 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
+9
View File
@@ -1698,6 +1698,15 @@
></div> ></div>
</div> </div>
<div id="approve-sign-hex-section" class="mb-3 hidden">
<div class="text-xs text-muted mb-1">Raw data</div>
<div
id="approve-sign-hex"
class="text-xs break-all"
style="max-height: 6rem; overflow-y: auto"
></div>
</div>
<div class="mb-2"> <div class="mb-2">
<label class="block mb-1 text-xs">Password</label> <label class="block mb-1 text-xs">Password</label>
<input <input
+10
View File
@@ -64,3 +64,13 @@ body {
white-space: nowrap; white-space: nowrap;
overflow-x: auto; overflow-x: auto;
} }
/* 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. */
.am-byte-order {
direction: ltr;
unicode-bidi: bidi-override;
}
+57 -11
View File
@@ -26,6 +26,7 @@ const {
} = require("ethers"); } = require("ethers");
const { getPrice, formatUsd } = require("../../shared/prices"); const { getPrice, formatUsd } = require("../../shared/prices");
const { ERC20_ABI } = require("../../shared/constants"); const { ERC20_ABI } = require("../../shared/constants");
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
const { const {
resolveTokenDecimals, resolveTokenDecimals,
resolveTokenSymbol, resolveTokenSymbol,
@@ -380,20 +381,48 @@ 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) { function decodeHexMessage(hex) {
try { try {
const bytes = Uint8Array.from( return toUtf8String(getBytes(hex));
hex
.slice(2)
.match(/.{1,2}/g)
.map((b) => parseInt(b, 16)),
);
return toUtf8String(bytes);
} catch { } catch {
return null; 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), 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.
function markInvisibleCharacters(text) {
return escapeHtml(text)
.replace(INVISIBLE_CHARACTERS, codePointMark)
.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
// `primaryType`: it takes the one struct in `types` that no other struct // `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 // refers to. Throws when the types name no such single struct, which ethers
@@ -657,15 +686,33 @@ function showSignApproval(details) {
? "Typed data (EIP-712)" ? "Typed data (EIP-712)"
: "Personal message"; : "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) { if (isTyped) {
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData); $("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
} else { refusal = typedDataRefusal(sp);
} else if (isHexMessage(sp.message)) {
const decoded = decodeHexMessage(sp.message); const decoded = decodeHexMessage(sp.message);
if (decoded !== null) { if (decoded !== null) {
$("approve-sign-message").textContent = decoded; $("approve-sign-message").innerHTML =
markInvisibleCharacters(decoded);
} else { } 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) // Display danger warning for eth_sign (raw hash signing)
@@ -687,7 +734,6 @@ function showSignApproval(details) {
showView("approve-sign"); showView("approve-sign");
attachCopyHandlers("view-approve-sign"); attachCopyHandlers("view-approve-sign");
const refusal = typedDataRefusal(sp);
if (refusal) { if (refusal) {
showError("approve-sign-error", refusal); showError("approve-sign-error", refusal);
$("btn-approve-sign").disabled = true; $("btn-approve-sign").disabled = true;
+1 -1
View File
@@ -395,7 +395,7 @@ async function estimateGas(txInfo) {
feeWei = gasCostWei; feeWei = gasCostWei;
renderValidation(txInfo); renderValidation(txInfo);
} catch (e) { } catch (e) {
log.errorf("gas estimation failed:", e.shortMessage || e.message); log.errorf("gas estimation failed:", e.message);
if (pendingTx !== txInfo) return; if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate"; $("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false); setVisible("confirm-fee-reserve", false);
+5 -14
View File
@@ -16,12 +16,7 @@ const {
} = require("../dustThreshold"); } = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch"); const { onChainSwitch } = require("../../shared/chainSwitch");
const { const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
log,
debugFetch,
urlOrigin,
setRuntimeDebug,
} = require("../../shared/log");
const deleteWallet = require("./deleteWallet"); const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase"); const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet"); const { walletHasRecoveryPhrase } = require("../../shared/wallet");
@@ -277,11 +272,8 @@ function init(ctx) {
showFlash("Wrong network: expected " + net.name + "."); showFlash("Wrong network: expected " + net.name + ".");
return; return;
} }
} catch { } catch (e) {
// Not the error's message: fetch puts the whole URL, password and log.errorf("RPC validation fetch failed:", e.message);
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
log.errorf("RPC validation fetch failed:", urlOrigin(url));
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
@@ -303,9 +295,8 @@ function init(ctx) {
showFlash("Endpoint returned HTTP " + resp.status + "."); showFlash("Endpoint returned HTTP " + resp.status + ".");
return; return;
} }
} catch { } catch (e) {
// Not the error's message, as for the RPC check above. log.errorf("Blockscout validation failed:", e.message);
log.errorf("Blockscout validation failed:", urlOrigin(url));
showFlash("Could not reach endpoint."); showFlash("Could not reach endpoint.");
return; return;
} }
+1 -1
View File
@@ -133,7 +133,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// failed — which matters most on a resumed wait, where the // failed — which matters most on a resumed wait, where the
// first poll is already past the deadline. // first poll is already past the deadline.
answered = false; answered = false;
log.errorf("poll receipt failed:", e.shortMessage || e.message); log.errorf("poll receipt failed:", e.message);
} }
// The lookup is async: the wait may have ended while it was in // The lookup is async: the wait may have ended while it was in
// flight, in which case this result must not touch the view. // flight, in which case this result must not touch the view.
+2 -2
View File
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
}); });
} }
} catch (e) { } catch (e) {
log.errorf("contract check failed:", e.shortMessage || e.message); log.errorf("contract check failed:", e.message);
} }
// Skip tx count check for contracts — they may legitimately have // Skip tx count check for contracts — they may legitimately have
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
}); });
} }
} catch (e) { } catch (e) {
log.errorf("tx count check failed:", e.shortMessage || e.message); log.errorf("tx count check failed:", e.message);
} }
} }
+5 -8
View File
@@ -10,7 +10,7 @@ const {
} = require("ethers"); } = require("ethers");
const { ERC20_ABI } = require("./constants"); const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks"); const { NETWORKS } = require("./networks");
const { log, debugFetch, urlOrigin } = require("./log"); const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet"); const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList"); const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders"); const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
@@ -203,7 +203,7 @@ async function refreshBalances(
trackedTokens, trackedTokens,
networkId, networkId,
) { ) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl)); log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const updates = []; const updates = [];
@@ -246,7 +246,7 @@ async function refreshBalances(
log.errorf( log.errorf(
"ENS reverse failed", "ENS reverse failed",
addr.address, addr.address,
e.shortMessage || e.message, e.message,
); );
// Keep existing addr.ensName if we had one // Keep existing addr.ensName if we had one
}), }),
@@ -280,7 +280,7 @@ async function refreshBalances(
// Look up token metadata from its contract. // Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20. // Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) { async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl)); log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId); const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider); const contract = new Contract(contractAddress, ERC20_ABI, provider);
@@ -305,10 +305,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
name = await contract.name(); name = await contract.name();
log.debugf("name() =", name); log.debugf("name() =", name);
} catch (e) { } catch (e) {
log.warnf( log.warnf("name() failed, using symbol as name:", e.message);
"name() failed, using symbol as name:",
e.shortMessage || e.message,
);
name = symbol; name = symbol;
} }
+1 -5
View File
@@ -42,11 +42,7 @@ async function resolveEnsName(address, rpcUrl, networkId) {
setCache(address, name); setCache(address, name);
return name; return name;
} catch (e) { } catch (e) {
log.errorf( log.errorf("ENS reverse lookup failed", address, e.message);
"ENS reverse lookup failed",
address,
e.shortMessage || e.message,
);
// Don't cache failures — let subsequent lookups retry // Don't cache failures — let subsequent lookups retry
return null; return null;
} }
+5 -25
View File
@@ -42,34 +42,14 @@ const log = {
}, },
}; };
// The origin (scheme, host and port) of a URL, for logging in place of the // Fetch wrapper that debug-logs every request and response.
// URL: RPC providers put API keys in the path or the query string, and a URL
// can carry a user name and password, which the origin leaves out. A URL that
// does not parse gives "", so logging never stops a request.
function urlOrigin(url) {
try {
return new URL(url).origin;
} catch {
return "";
}
}
// Fetch wrapper that debug-logs every request and response. It logs the
// URL's origin and, for a JSON-RPC body, the method name: never the full URL
// or body, which can carry an API key or a signed transaction.
async function debugFetch(url, opts) { async function debugFetch(url, opts) {
const method = (opts && opts.method) || "GET"; const method = (opts && opts.method) || "GET";
const origin = urlOrigin(url); const body = opts && opts.body;
let rpcMethod = ""; log.debugf("fetch →", method, url, body || "");
try {
rpcMethod = JSON.parse(opts.body).method || "";
} catch {
// no body, or a body that is not JSON
}
log.debugf("fetch →", method, origin, rpcMethod);
const resp = await fetch(url, opts); const resp = await fetch(url, opts);
log.debugf("fetch ←", resp.status, origin); log.debugf("fetch ←", resp.status, url);
return resp; return resp;
} }
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug }; module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
+7 -1
View File
@@ -34,6 +34,11 @@ function normalizeAddress(addr) {
return (addr || "").toLowerCase(); return (addr || "").toLowerCase();
} }
// The characters that paint nothing; normalizeSymbol below says which they
// are. The signature screen marks them in a personal message
// (src/popup/views/approval.js).
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
// Fold a symbol onto what a user actually sees, and no further: // Fold a symbol onto what a user actually sees, and no further:
// //
// NFKC collapses compatibility variants that render as the ASCII // NFKC collapses compatibility variants that render as the ASCII
@@ -82,7 +87,7 @@ function normalizeAddress(addr) {
function normalizeSymbol(symbol) { function normalizeSymbol(symbol) {
return String(symbol || "") return String(symbol || "")
.normalize("NFKC") .normalize("NFKC")
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "") .replace(INVISIBLE_CHARACTERS, "")
.trim() .trim()
.toUpperCase(); .toUpperCase();
} }
@@ -104,5 +109,6 @@ function isSpoofedSymbol(symbol, contractAddress) {
} }
module.exports = { module.exports = {
INVISIBLE_CHARACTERS,
isSpoofedSymbol, isSpoofedSymbol,
}; };
-53
View File
@@ -1,53 +0,0 @@
// What debugFetch writes to the console in debug mode.
//
// RPC providers put the API key in the URL's path or query string, and the
// debug log used to print the whole URL and request body, so turning debug
// mode on wrote the key to the console
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
// the request.
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
setRuntimeDebug(false);
jest.restoreAllMocks();
});
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
setRuntimeDebug(true);
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
await debugFetch(
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_chainId",
params: [],
}),
},
);
const logged = consoleLog.mock.calls.flat().join(" ");
expect(logged).not.toContain("PATHKEY123");
expect(logged).not.toContain("QUERYTOKEN456");
expect(logged).toContain("https://rpc.example.invalid");
expect(logged).toContain("eth_chainId");
});
test("the origin leaves out a user name and password in the URL", () => {
expect(
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
).toBe("https://rpc.example.invalid");
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
"wss://rpc.example.invalid:8546",
);
});
+58
View File
@@ -3232,6 +3232,64 @@ 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
// (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";
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
hexlify(toUtf8Bytes(text)),
env.expectedAddress,
]);
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(() => {
const message = document.getElementById("approve-sign-message");
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
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);
}
text += node.data;
}
return { text, lefts };
});
await clickAndClose(popup, "#btn-reject-sign");
await assertUserRejection(
env.dapp,
"sign-bidi",
"the byte-order personal_sign rejection",
);
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]),
"the personal message is not laid out in byte order: " +
JSON.stringify(shown.lefts),
);
});
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => { test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [ await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
env.expectedAddress, env.expectedAddress,
+229
View File
@@ -0,0 +1,229 @@
// 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, markup shown as
// text, 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
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { hexlify, toUtf8Bytes } = require("ethers");
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const FROM = "0x0000000000000000000000000000000000000a11";
// Built from their code points so that this file holds none of them.
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
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();
return {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the signature prompt for a personal_sign of `message`, the way the
// popup does: it asks the background for the approval and show() draws it.
async function openPersonalSign(message) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "sign",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
signParams: { method: "personal_sign", message, from: FROM },
});
},
};
approval.init({});
await approval.show(1);
}
// The message box's markup as the text a reader sees: tags dropped.
function shownMessage() {
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
}
beforeEach(() => {
state.wallets = [];
state.activeAddress = FROM;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("a right-to-left override is marked, so the text reads in byte order", async () => {
// Obeyed, the override shows "0001" as "1000".
const text =
"Pay " +
RIGHT_TO_LEFT_OVERRIDE +
"0001" +
POP_DIRECTIONAL_FORMATTING +
" ETH";
await openPersonalSign(hexlify(toUtf8Bytes(text)));
const html = node("approve-sign-message").innerHTML;
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
});
test("a zero-width character is marked", async () => {
await openPersonalSign(
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
);
expect(node("approve-sign-message").innerHTML).not.toContain(
ZERO_WIDTH_SPACE,
);
expect(shownMessage()).toBe("payU+200Bpal.com");
});
test("variation selectors and a Hangul filler are marked", async () => {
// Each paints nothing, so a page could hide bytes after "Sign in".
await openPersonalSign(
hexlify(
toUtf8Bytes(
"Sign in" +
VARIATION_SELECTOR_1 +
VARIATION_SELECTOR_17 +
HANGUL_FILLER,
),
),
);
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
});
test("the message is laid out in byte order", async () => {
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
expect(
node("approve-sign-message").classList.contains("am-byte-order"),
).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");
});
// The message box is written as HTML, so a site's markup has to arrive there
// escaped, as the text it is.
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
test.each([
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
["a message that is not hex", MARKUP],
])("markup in %s is shown as text, not as markup", async (_, message) => {
await openPersonalSign(message);
expect(node("approve-sign-message").innerHTML).toBe(
"&lt;b&gt;x&lt;/b&gt;&lt;img src=x onerror=alert(1)&gt;",
);
});
test("the raw hex is shown alongside the text", async () => {
await openPersonalSign("0x48656c6c6f");
expect(shownMessage()).toBe("Hello");
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
false,
);
});
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
await openPersonalSign("0X48656C6C6F");
expect(shownMessage()).toBe("Hello");
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
expect(node("btn-approve-sign").disabled).toBe(false);
});
test("bytes that are not text are shown only as hex", async () => {
await openPersonalSign("0xff00");
expect(node("approve-sign-message").textContent).toBe(
"This message is not text.",
);
expect(node("approve-sign-hex").textContent).toBe("0xff00");
});
test("a message that is not hex is shown as text and cannot be signed", async () => {
await openPersonalSign("Hello world");
expect(shownMessage()).toBe("Hello world");
expect(node("approve-sign-error").textContent).toBe(
"This message is plain text, not hex, so it cannot be signed.",
);
expect(node("btn-approve-sign").disabled).toBe(true);
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
true,
);
});
-105
View File
@@ -1,105 +0,0 @@
// What reaches the console when the RPC endpoint answers with an HTTP error.
//
// RPC providers put the API key in the endpoint URL's path or query string.
// When the endpoint answers with an HTTP error (a wrong or expired key, a rate
// limit, a server error), the error ethers throws carries the full request URL
// in its message, so a line logging that message printed the key
// (https://git.eeqj.de/sneak/AutistMask/issues/410). Those lines log the
// error's short message, which names the HTTP status and not the URL.
//
// The real ethers provider runs; only its HTTP transport is replaced, by one
// that answers every request with 401 Unauthorized. Debug mode is on, so
// every log level is printed.
const { FetchRequest } = require("ethers");
const {
getProvider,
lookupTokenInfo,
refreshBalances,
} = require("../src/shared/balances");
const { getFullWarnings } = require("../src/shared/addressWarnings");
const { resolveEnsName } = require("../src/shared/ens");
const { setRuntimeDebug } = require("../src/shared/log");
const RPC_URL = "https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
const ADDRESS = "0x1111111111111111111111111111111111111111";
const realFetch = globalThis.fetch;
let printed;
beforeEach(() => {
setRuntimeDebug(true);
printed = [];
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
printed.push(args.map(String).join(" "));
});
}
FetchRequest.registerGetUrl(async () => ({
statusCode: 401,
statusMessage: "Unauthorized",
headers: {},
body: new Uint8Array(),
}));
// The explorer requests the balance refresh makes go nowhere.
globalThis.fetch = jest.fn(async () => {
throw new Error("tests must not perform network requests");
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
setRuntimeDebug(false);
jest.restoreAllMocks();
});
// The line carrying `label` was printed and names the HTTP status, and nothing
// printed carries the key.
function expectFailureLoggedWithoutKey(label) {
const line = printed.find((text) => text.includes(label));
expect(line).toContain("401");
const all = printed.join("\n");
expect(all).not.toContain("PATHKEY123");
expect(all).not.toContain("QUERYTOKEN456");
}
test("ethers puts the URL in the error message, not in the short message", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const error = await provider.getCode(ADDRESS).catch((e) => e);
expect(error.message).toContain("PATHKEY123");
expect(error.shortMessage).not.toContain("PATHKEY123");
});
test("the recipient checks before a send", async () => {
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
expectFailureLoggedWithoutKey("contract check failed");
expectFailureLoggedWithoutKey("tx count check failed");
});
test("the ENS reverse lookup", async () => {
expect(await resolveEnsName(ADDRESS, RPC_URL, "mainnet")).toBeNull();
expectFailureLoggedWithoutKey("ENS reverse lookup failed");
});
test("the balance refresh", async () => {
const wallets = [{ addresses: [{ address: ADDRESS }] }];
await refreshBalances(
wallets,
RPC_URL,
"https://explorer.example.invalid/api/v2",
[],
"mainnet",
);
expectFailureLoggedWithoutKey("ETH balance failed");
expectFailureLoggedWithoutKey("ENS reverse failed");
});
// The lookup's first line, at debug level, names the RPC endpoint; the check
// of everything printed covers it too.
test("the token lookup", async () => {
await expect(lookupTokenInfo(ADDRESS, RPC_URL, "mainnet")).rejects.toThrow(
"Not a valid ERC-20 token",
);
expectFailureLoggedWithoutKey("symbol() failed:");
});
-1
View File
@@ -66,7 +66,6 @@ jest.mock("../src/shared/log", () => ({
status: 200, status: 200,
json: async () => mockExplorer.items, json: async () => mockExplorer.items,
})), })),
urlOrigin: () => "",
setRuntimeDebug: () => {}, setRuntimeDebug: () => {},
isDebug: () => false, isDebug: () => false,
})); }));
-148
View File
@@ -1,148 +0,0 @@
// What reaches the console when an endpoint check in Settings fails.
//
// fetch refuses a URL with a user name and password in it, or one it cannot
// parse, with an error whose message carries the whole URL: the password, and
// any API key in the path or query string. The checks behind the RPC and
// Blockscout Save buttons printed that message
// (https://git.eeqj.de/sneak/AutistMask/issues/410); they now name the
// endpoint by its origin.
//
// The real fetch runs; it throws before making any request. Debug mode is on,
// so every log level is printed.
const SECRETS = ["SECRETPASS789", "PATHKEY123", "QUERYTOKEN456"];
const RPC_WITH_PASSWORD =
"https://user:SECRETPASS789@rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
// Port 99999 is out of range, so the URL does not parse.
const RPC_UNPARSEABLE =
"https://rpc.example.invalid:99999/v3/PATHKEY123?token=QUERYTOKEN456";
const BLOCKSCOUT_WITH_PASSWORD =
"https://user:SECRETPASS789@explorer.example.invalid/PATHKEY123/api/v2";
const SAVED_RPC = "https://saved-rpc.example.invalid";
const SAVED_BLOCKSCOUT = "https://saved-explorer.example.invalid/api/v2";
let elements;
let flashes;
let printed;
let state;
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
href: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
function element(id) {
return (elements[id] ||= fakeElement());
}
function loadSettingsView() {
elements = {};
flashes = [];
jest.resetModules();
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
updateDebugBanner: () => {},
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
state = require("../src/shared/state").state;
state.rpcUrl = SAVED_RPC;
state.blockscoutUrl = SAVED_BLOCKSCOUT;
require("../src/shared/log").setRuntimeDebug(true);
require("../src/popup/views/settings").init({});
}
async function save(fieldId, buttonId, typed) {
element(fieldId).value = typed;
await element(buttonId).listeners.click();
}
// The check failed, nothing was saved, and nothing printed carries the
// password or the key.
function expectFailedWithoutSecrets(label) {
expect(flashes).toContain("Could not reach endpoint.");
expect(state.rpcUrl).toBe(SAVED_RPC);
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
const line = printed.find((text) => text.includes(label));
expect(line).toBeDefined();
const all = printed.join("\n");
for (const secret of SECRETS) {
expect(all).not.toContain(secret);
}
return line;
}
beforeEach(() => {
printed = [];
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
printed.push(args.map(String).join(" "));
});
}
globalThis.chrome = {
runtime: { sendMessage: () => {} },
storage: { local: { get: async () => ({}), set: async () => {} } },
};
});
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
delete globalThis.chrome;
jest.restoreAllMocks();
});
test("fetch puts the whole URL in the error it throws for such a URL", async () => {
for (const url of [RPC_WITH_PASSWORD, RPC_UNPARSEABLE]) {
const error = await fetch(url).catch((e) => e);
expect(error.message).toContain("PATHKEY123");
}
});
test("the RPC check of a URL with a user name and password", async () => {
loadSettingsView();
await save("settings-rpc", "btn-save-rpc", RPC_WITH_PASSWORD);
const line = expectFailedWithoutSecrets("RPC validation fetch failed");
expect(line).toContain("https://rpc.example.invalid");
});
test("the RPC check of a URL that does not parse", async () => {
loadSettingsView();
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
expectFailedWithoutSecrets("RPC validation fetch failed");
});
test("the Blockscout check of a URL with a user name and password", async () => {
loadSettingsView();
await save(
"settings-blockscout",
"btn-save-blockscout",
BLOCKSCOUT_WITH_PASSWORD,
);
const line = expectFailedWithoutSecrets("Blockscout validation failed");
expect(line).toContain("https://explorer.example.invalid");
});
-1
View File
@@ -44,7 +44,6 @@ jest.mock("../src/shared/log", () => ({
errorf: () => {}, errorf: () => {},
}, },
debugFetch: jest.fn(), debugFetch: jest.fn(),
urlOrigin: () => "",
setRuntimeDebug: () => {}, setRuntimeDebug: () => {},
isDebug: () => false, isDebug: () => false,
})); }));