Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b99d421b8 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
"<b>x</b><img src=x onerror=alert(1)>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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:");
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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");
|
|
||||||
});
|
|
||||||
@@ -44,7 +44,6 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
errorf: () => {},
|
errorf: () => {},
|
||||||
},
|
},
|
||||||
debugFetch: jest.fn(),
|
debugFetch: jest.fn(),
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
setRuntimeDebug: () => {},
|
||||||
isDebug: () => false,
|
isDebug: () => false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user