Compare commits

..
Author SHA1 Message Date
sneak 01dce26807 fix: approval windows open as wide as the page (closes #518)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The approval window was opened 360 pixels wide while the page is 396, so
it scrolled sideways and cut off the Reject button. It now opens at 396.
Both browsers count the window's frame in that width, so the approval
page also widens its own window by however far it would scroll sideways;
the toolbar popup's window is left alone. The Firefox suite checks that
the sign prompt does not scroll sideways. The Chrome suite cannot: its
approval windows open at 1280x720 whatever size is asked. README's Visual
Style line, which still said 360x600, now gives the page's real width.

Model: opus-5-5
2026-10-14 11:50:23 +00:00
clawbot 2442441830 fix: use the gas limit a site sends as gas (closes #207)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
Sites send the gas limit under its JSON-RPC name, `gas`. ethers only
reads `gasLimit`, so the field was dropped and the node's estimate was
shown and signed instead. The page's request now takes `gas` as the gas
limit, and the existing ceiling, display and comparison with the signed
transaction cover it as they already did `gasLimit`. A request that
gives both with different values is refused.

Model: opus-5-5
2026-10-14 13:48:52 +02:00
clawbot f1a13a50a6 fix: the defective xprv wallet notice names the keys that still reach its addresses (closes #255)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The notice told the user that these addresses are reachable by software
that repeats the same derivation, while the same wallet blocks Export
Private Key, so there was no way to take that route from here. The
second paragraph now names private key export among the disabled
controls and says the addresses can be reached outside this wallet with
the extended private key the user imported, if they kept a copy of it,
or with the master extended private key or recovery phrase it came
from. No behaviour changes. A test checks the new wording.

Model: opus-5-5
2026-10-14 11:48:46 +02:00
clawbot b4059af827 fix: a listed site ending in a dot gets the phishing warning (closes #534)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The blocklist check hashed the hostname as the page gave it, so
"myetheywallet.com." and every subdomain written that way hashed
differently from the listed "myetheywallet.com" and opened approval
prompts with no warning. The browser and DNS treat both spellings as
one host. hostnameVariants() now drops one trailing dot before
building the hostname and its parent domains, so the check sees the
name the list holds.

Model: opus-5-5
2026-10-14 10:48:41 +02:00
clawbot 8c08b3d467 docs: say what a closed window does to a tx or sign approval (closes #505)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
The approval port's disconnect handler leaves a transaction or signing
approval pending, and its comments said that was so the user could reopen
the toolbar popup. Those approvals open in their own window. When that
window closes, the windows.onRemoved listener rejects the approval, unless
an approved attempt is still running, in which case that attempt answers
the page. Both comments now say so. No code changed.

Model: opus-5-5
2026-10-14 09:48:41 +02:00
clawbot fd5921f62e fix: make fmt refuses to run without the repo's own prettier (closes #509)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run
In a clone where script/bootstrap has not run, node_modules/ is empty and
yarn falls back to whatever prettier is on the PATH, which can format files
differently from the version yarn.lock pins. script/fmt and script/fmt-check
now stop with a message telling the user to run script/bootstrap when
node_modules/.bin/prettier is missing. After bootstrap they behave as
before. The README Entrypoints entries say so.

Model: opus-5-5
2026-10-14 09:31:47 +02:00
19 changed files with 282 additions and 321 deletions
+21 -13
View File
@@ -239,8 +239,12 @@ provide:
`make check` stays non-mutating. Linting runs only in the container, because
an ESLint result that depends on whichever ESLint the host happens to have is
not a result, so docker is required to lint.
- `script/fmt` — format all files (writes), on the host
- `script/fmt-check` — check formatting (read-only), on the host
- `script/fmt` — format all files (writes), on the host, with the `prettier`
that `script/bootstrap` installs in `node_modules/`; when that one is missing
it fails and says to run `script/bootstrap`, instead of using a `prettier`
from the `PATH`
- `script/fmt-check` — check formatting (read-only), on the host, with the same
`prettier` and the same refusal
- `script/check` — run `script/test`, `script/lint` and `script/fmt-check`
- `script/check-censored` — assert the competitor name RULES.md bars appears
nowhere in the working tree or under `dist/` outside its documented
@@ -460,6 +464,9 @@ papered over:
forced by clicking Reveal and the settings gear in one page task, which a
person cannot do; the case a person can hit, the first decrypt after the popup
opens while libsodium is still loading, is not driven.
- Approval windows open at 1280x720 whatever size the extension asks for, so
whether an approval screen fits its window is checked in the Firefox suite
only.
Any test that drives a failure path on purpose declares the `console.error` it
is about to provoke, via `errors.expect()`. That is not a mute: the declaration
@@ -532,7 +539,8 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script,
background page and approval windows.
background page and approval windows. The `personal_sign` prompt is also
required to fit its window without scrolling sideways.
The suite lives in `tests/e2e/firefox/`. Its WebDriver client (`driver.js`) has
**no npm dependencies at all**: it is built on global `fetch` and
@@ -889,8 +897,11 @@ discoverable.
enough that vanilla JS with simple view switching is sufficient. A framework
would add bundle size, build complexity, and attack surface for no benefit at
this scale.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is
designed for this fixed viewport.
- **396x600 popup**: The page is 396 pixels wide (`body` in
`src/popup/styles/main.css`), and 600 pixels is as tall as the browser lets
the toolbar popup grow. The UI is designed for this fixed viewport. The window
an approval opens in is given the same size; the browser counts the window's
frame in its width, so the page widens the window by whatever the frame takes.
#### No Layout Shift
@@ -1641,12 +1652,6 @@ view would leave a wallet one click from deletion.
the contract's own answer is read at signing time only to be compared with
it, and a disagreement is a refusal rather than a preference for either
value (`src/shared/transferAmount.js`)
- "Sign & Send" after the active network was switched in Settings while this
screen was open → nothing is signed; "The active network changed after
this transaction was reviewed, so it was not sent. Please go back and
review it again." in the errors box, no screen change. The transaction
carries the chain id of the network the screen was shown for, so it cannot
be signed for another
- "Sign & Send" (wrong password) → "Wrong password." on the password error
line, no screen change
- "Back" → **Send**
@@ -2077,7 +2082,9 @@ view would leave a wallet one click from deletion.
artifact can be compared with it field for field. A nonce the site supplies is
ignored: the nonce is always the account's next nonce from the node, so a site
cannot replace one of the user's pending transactions or leave this one stuck
behind a gap. A request that cannot be populated — unreachable node, reverting
behind a gap. A gas limit the site supplies, as `gas` or `gasLimit`, is used
in place of the node's estimate; a request giving both with different values
is refused. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with
@@ -2665,7 +2672,8 @@ plaintext list of anyone's domain names.
When a dApp on a blocklisted domain requests a wallet connection, transaction
approval, or signature, the approval popup displays a prominent red warning
banner alerting the user. The domain checker matches exact hostnames and all
parent domains (subdomain matching).
parent domains (subdomain matching), and checks a hostname written with a
trailing dot (`name.`) as `name`.
#### Transaction Decoding
+7
View File
@@ -25,6 +25,13 @@ run_yarn() {
main() {
cd "$ROOT"
# Without the repo's own prettier, yarn runs whatever prettier is on
# the PATH, which may be a version that formats files differently.
if [ ! -x node_modules/.bin/prettier ]; then
echo "fmt: this repo's prettier is not installed in node_modules/," \
"so run script/bootstrap first." >&2
exit 1
fi
echo "Formatting..."
run_yarn run fmt
}
+7
View File
@@ -26,6 +26,13 @@ run_yarn() {
main() {
cd "$ROOT"
# Without the repo's own prettier, yarn runs whatever prettier is on
# the PATH, which may be a version that formats files differently.
if [ ! -x node_modules/.bin/prettier ]; then
echo "fmt-check: this repo's prettier is not installed in" \
"node_modules/, so run script/bootstrap first." >&2
exit 1
fi
echo "Checking formatting..."
run_yarn run fmt-check
}
+11 -5
View File
@@ -423,7 +423,10 @@ function releaseApproval(approval) {
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
// As wide as the page: body in src/popup/styles/main.css. The browser
// counts the window's frame in this width; the page itself adds back
// whatever the frame takes (src/popup/index.js).
const popupWidth = 396;
const popupHeight = 600;
let currentWin = null;
@@ -443,7 +446,7 @@ async function openApprovalWindow(id) {
// Centred on a browser window only. The last focused window can be
// another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within
// visible screen space"): headless Chrome reports this 360x600 popup as
// visible screen space"): headless Chrome reports this 396x600 popup as
// 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position.
if (currentWin && currentWin.type === "normal") {
@@ -633,8 +636,9 @@ function isExtensionSender(sender) {
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
// disconnect. When their window closes, the windows.onRemoved listener below
// rejects them, unless an approved attempt is still running, in which case
// that attempt answers the page.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
@@ -662,7 +666,9 @@ runtime.onConnect.addListener((port) => {
const approval = pendingApprovals[id];
if (approval) {
if (approval.type === "tx" || approval.type === "sign") {
// Keep pending — user can reopen the toolbar popup
// When its window closes, windows.onRemoved rejects it,
// unless an approved attempt is still running, in which
// case that attempt answers the page.
return;
}
settleApproval(id, { approved: false, remember: false });
+17
View File
@@ -21,6 +21,7 @@ const {
goBack,
} = require("./views/helpers");
const { applyTheme } = require("./theme");
const { windowsGetCurrent, windowsUpdate } = require("../shared/browserApi");
// Renders a view the popup lands on without having navigated to it forward:
// on restore here, and on Back. Only the views that can be fully re-rendered
// from persisted state (RESTORABLE_VIEWS, src/shared/restorableViews.js) go
@@ -233,6 +234,22 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
// The background opens this window as wide as the page, but the
// browser counts the window's frame in that width, and how wide the
// frame is depends on the browser and the system. The window is
// widened by however far the page would scroll sideways. The
// connection prompt can also show in the toolbar popup, whose window
// is the user's browser window; that one is left alone.
const sideways =
document.documentElement.scrollWidth -
document.documentElement.clientWidth;
if (sideways > 0) {
windowsGetCurrent().then((win) => {
if (win.type === "popup") {
windowsUpdate(win.id, { width: win.width + sideways });
}
});
}
// Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing
+5 -21
View File
@@ -101,10 +101,6 @@ function show(txInfo) {
feeStatus = FEE_PENDING;
feeWei = null;
maxSendFees = null;
// The network this screen is drawn for, and the only one it sends on:
// Settings can switch the active network while the screen is open. The
// wait, success and error screens label the amount by it too.
txInfo.chainId = currentNetwork().chainId;
const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the
@@ -494,12 +490,7 @@ async function populateVerifyAndSend(connectedSigner, tx, fees = null) {
);
request = await contract.transfer.populateTransaction(tx.to, amount);
}
// The chain id of the network the screen was drawn for: ethers refuses to
// populate it for a provider on any other network.
const populated = await connectedSigner.populateTransaction({
...request,
chainId: tx.chainId,
});
const populated = await connectedSigner.populateTransaction(request);
assertWithinCeilings(populated);
return connectedSigner.sendTransaction(populated);
}
@@ -556,17 +547,6 @@ function init(_ctx) {
return;
}
// Back from Settings only unhides this screen, still drawn for the
// network that was active when it was shown.
if (currentNetwork().chainId !== pendingTx.chainId) {
showSendError(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
return;
}
const password = $("confirm-tx-password").value;
if (!password) {
showError(
@@ -595,6 +575,10 @@ function init(_ctx) {
$("btn-confirm-send").disabled = true;
$("btn-confirm-send").classList.add("text-muted");
// The network it is sent on. The wait, success and error screens
// label its amount by this, not by the network active when they draw.
pendingTx.chainId = currentNetwork().chainId;
let tx;
try {
const signer = getSignerForAddress(
+39 -12
View File
@@ -1,13 +1,14 @@
// Preparation of the transaction an approval screen displays.
//
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`.
// The nonce, the gas limit and the fees have to be filled in from the network
// before anything can be signed, and whoever fills them in decides what the
// user is shown. That work used to happen in the popup, after the user had
// already approved: the numbers on the approval screen came from the popup and
// were compared against nothing, so a compromised popup could display one fee
// and sign another, and the ceilings in approvalVerify.js were all that stood
// between the user and a fee that hands the validator the balance.
// A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
// sometimes the gas limit. The nonce, the fees, and the gas limit when the
// site gives none have to be filled in from the network before anything can
// be signed, and whoever fills them in decides what the user is shown. That
// work used to happen in the popup, after the user had already approved: the
// numbers on the approval screen came from the popup and were compared against
// nothing, so a compromised popup could display one fee and sign another, and
// the ceilings in approvalVerify.js were all that stood between the user and a
// fee that hands the validator the balance.
//
// So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and
@@ -31,6 +32,7 @@ const {
VoidSigner,
accessListify,
getAddress,
getBigInt,
getBytes,
hexlify,
toQuantity,
@@ -47,10 +49,11 @@ const {
// minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page. Anything else is dropped rather than
// passed to ethers: the object is page-controlled, and a future ethers that
// learns to carry a new transaction field must not start picking one up out of
// it without this module knowing.
// The request fields taken from the page, plus `gas`, which requestFrom()
// takes as the gas limit. Anything else is dropped rather than passed to
// ethers: the object is page-controlled, and a future ethers that learns to
// carry a new transaction field must not start picking one up out of it
// without this module knowing.
//
// The nonce is not taken from the page; it is always the account's next nonce
// from the network. A page that chose it could replace one of the user's
@@ -84,6 +87,16 @@ function present(v) {
return v !== null && v !== undefined && v !== "";
}
// Whether two quantities from the page are the same number. One that is not a
// number matches nothing.
function sameQuantity(a, b) {
try {
return getBigInt(a) === getBigInt(b);
} catch {
return false;
}
}
// These strings reach the user through the requesting page, so they are full
// sentences even when the tail of one came from ethers or from the node.
function sentence(text) {
@@ -111,6 +124,20 @@ function requestFrom(txParams, from) {
for (const key of REQUEST_FIELDS) {
if (present(txParams[key])) request[key] = txParams[key];
}
// JSON-RPC calls the gas limit `gas`, and that is the name sites send.
// ethers only reads `gasLimit`: left as `gas` it would be dropped and an
// estimate signed in its place.
if (present(txParams.gas)) {
if (
present(request.gasLimit) &&
!sameQuantity(request.gasLimit, txParams.gas)
) {
throw fail(
"The site asked for two different gas limits for this transaction.",
);
}
request.gasLimit = txParams.gas;
}
if (
present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type))
+18
View File
@@ -250,6 +250,22 @@ function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @returns {Promise<Object>} the window the calling page is in.
*/
function windowsGetCurrent() {
return invoke(windowsApi(), "getCurrent");
}
/**
* @param {number} windowId
* @param {Object} updateInfo
* @returns {Promise<Object>} the updated window.
*/
function windowsUpdate(windowId, updateInfo) {
return invoke(windowsApi(), "update", windowId, updateInfo);
}
/**
* @param {number} windowId
* @returns {Promise<void>}
@@ -275,6 +291,8 @@ module.exports = {
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetCurrent,
windowsGetLastFocused,
windowsRemove,
windowsUpdate,
};
+4 -1
View File
@@ -110,11 +110,14 @@ function hashListed(hash) {
* Generate hostname variants for subdomain matching.
* "sub.evil.com" yields ["sub.evil.com", "evil.com"].
*
* A trailing dot is dropped: "evil.com." is the fully qualified spelling of
* "evil.com", and the browser loads the same site from either.
*
* @param {string} hostname
* @returns {string[]}
*/
function hostnameVariants(hostname) {
const h = hostname.toLowerCase();
const h = hostname.toLowerCase().replace(/\.$/, "");
const variants = [h];
const parts = h.split(".");
// Parent domains: a.b.c.d -> b.c.d, c.d
+7 -5
View File
@@ -36,11 +36,13 @@ const DEFECTS = {
"derivation path beneath that key instead of from a master " +
"key, so the addresses listed here are not the ones that key " +
"produces under the standard path.",
"Signing and sending are disabled for this wallet. The addresses " +
"do descend from the extended private key you imported, so " +
"anything they hold is still reachable by software that " +
"repeats the same non-standard derivation. Check them in a " +
"block explorer before deciding what to do.",
"Signing, sending and private key export are disabled for this " +
"wallet. Anything these addresses hold can be reached outside " +
"this wallet with the extended private key you imported, if " +
"you kept a copy of it, or with the master extended private " +
"key or recovery phrase it came from, in software that " +
"repeats the same non-standard derivation. Check the " +
"addresses in a block explorer before deciding what to do.",
"To see the addresses this key produces under the standard path, " +
"import the master extended private key, or the recovery " +
"phrase it came from, as a new wallet. Nothing here has been " +
+47 -1
View File
@@ -9,7 +9,7 @@
// requesting page or the RPC node can say turns it into an approval that
// should never have been raised.
const { Network, Wallet } = require("ethers");
const { Network, Transaction, Wallet, toQuantity } = require("ethers");
const {
prepareApprovalTx,
serializeApprovedTx,
@@ -138,6 +138,43 @@ describe("prepareApprovalTx", () => {
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
// `gas` is the JSON-RPC name for the gas limit and the one sites send.
// The node would estimate 21000; the site's 200000 is what is shown and
// what is signed.
test("uses the gas limit a site sends as gas", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{ ...TX_PARAMS, gas: "0x30d40" },
);
expect(approved.gasLimit).toBe("0x30d40");
const signed = Transaction.from(
await signer.signTransaction({ ...approved }),
);
expect(signed.gasLimit).toBe(200000n);
});
test("accepts gas and gasLimit that name the same number", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
{ ...TX_PARAMS, gas: "0x30d40", gasLimit: 200000 },
);
expect(approved.gasLimit).toBe("0x30d40");
});
test("refuses gas and gasLimit that disagree", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
gas: "0x30d40",
gasLimit: "0x5208",
}),
).rejects.toThrow(
"The site asked for two different gas limits for this transaction.",
);
});
test("carries an access list the request asked for", async () => {
const approved = await prepareApprovalTx(
providerWith(),
@@ -212,6 +249,15 @@ describe("prepareApprovalTx", () => {
).rejects.toThrow(/gas limit no network this wallet supports/);
});
test("refuses a gas limit the site sent above the ceiling", async () => {
await expect(
prepareApprovalTx(providerWith(), signer.address, {
...TX_PARAMS,
gas: toQuantity(MAX_GAS_LIMIT + 1n),
}),
).rejects.toThrow(/gas limit no network this wallet supports/);
});
// The combined bound at population: a gas limit and a fee that are each
// under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a
-1
View File
@@ -58,7 +58,6 @@ const TX_PARAMS = {
to: RECIPIENT,
value: "0x2386f26fc10000",
data: "0xdeadbeef",
gas: "0x5208",
};
// The values populateTransaction() fills in when the dApp fixed none of them.
+47
View File
@@ -45,3 +45,50 @@ test("the approval window shows no screen until the background describes the app
expect(env.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]);
});
// The background opens the approval window as wide as the page, but the
// browser counts the window's frame in that width
// (https://git.eeqj.de/sneak/AutistMask/issues/518). The browser suites run
// without frames, so the widening is checked here.
function windowsStub(type) {
const updates = [];
return {
updates,
getCurrent: (done) => done({ id: 7, type, width: 396 }),
update: (id, info, done) => {
updates.push({ id, info });
done({});
},
};
}
const approvalRuntime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: () => {},
};
test("the approval window widens itself by whatever its frame takes", async () => {
const windows = windowsStub("popup");
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: approvalRuntime,
windows,
documentElement: { scrollWidth: 396, clientWidth: 380 },
});
expect(windows.updates).toEqual([{ id: 7, info: { width: 412 } }]);
expect(env.pageErrors).toEqual([]);
});
test("a connection prompt in the toolbar popup leaves the browser window alone", async () => {
const windows = windowsStub("normal");
const env = await bootPopup(unversionedValidProfile(), {
search: "?approval=approval-1",
runtime: approvalRuntime,
windows,
documentElement: { scrollWidth: 396, clientWidth: 380 },
});
expect(windows.updates).toEqual([]);
expect(env.pageErrors).toEqual([]);
});
+3 -3
View File
@@ -2805,7 +2805,7 @@ describe("where an approval window opens", () => {
await settle();
expect(bg.created).toHaveLength(1);
expect(bg.created[0]).toMatchObject({ left: 460, top: 60 });
expect(bg.created[0]).toMatchObject({ left: 442, top: 60 });
});
test("not centred on an approval window the user was last in", async () => {
@@ -2822,7 +2822,7 @@ describe("where an approval window opens", () => {
bg.requestSign();
await settle();
// Centred, it would be at left 900, the position the browser refused.
// Centred, it would be at left 882, the position the browser refused.
expect(bg.created).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined();
@@ -2844,7 +2844,7 @@ describe("where an approval window opens", () => {
await settle();
expect(bg.created).toHaveLength(2);
expect(bg.created[0]).toMatchObject({ left: 1520, top: 750 });
expect(bg.created[0]).toMatchObject({ left: 1502, top: 750 });
expect(bg.created[1].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined();
-259
View File
@@ -1,259 +0,0 @@
// A transfer is sent only on the network its confirmation screen was drawn for
// (https://git.eeqj.de/sneak/AutistMask/issues/524).
//
// Settings is reachable from the confirmation screen, and Back from it only
// unhides the screen, still labelled for the network it was drawn for. Sign &
// Send used to take the network at the moment of the click, so 1 SepoliaETH
// reviewed on Sepolia went out as 1 ETH on mainnet once mainnet was chosen in
// Settings in between.
//
// Driven through the real confirmation screen, signer and chain switch, with
// only the node and the DOM stubbed. The stub node is on the network its
// provider was built for, so a broadcast records the network it went to.
"use strict";
// The signed transactions the stub node was handed, with its network's chain id.
const mockNode = { broadcast: [] };
jest.mock("ethers", () => {
const actual = jest.requireActual("ethers");
class StubProvider {
constructor(url, network) {
this.network = network;
}
async getNetwork() {
return this.network;
}
async getFeeData() {
return {
maxFeePerGas: 20000000000n,
maxPriorityFeePerGas: 1000000000n,
gasPrice: 10000000000n,
};
}
async estimateGas() {
return 21000n;
}
async getCode() {
return "0x";
}
async getTransactionCount() {
return 1;
}
async lookupAddress() {
return null;
}
async broadcastTransaction(signed) {
mockNode.broadcast.push({ chainId: this.network.chainId, signed });
return { hash: "0x" + "ab".repeat(32) };
}
}
return { ...actual, JsonRpcProvider: StubProvider };
});
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
debugFetch: jest.fn(async () => ({
ok: true,
status: 200,
json: async () => ({ items: [] }),
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; these tests stop at the broadcast.
jest.mock("../src/popup/views/txStatus", () => ({
showWait: jest.fn(),
showError: jest.fn(),
}));
global.fetch = jest.fn(() => {
throw new Error("tests must not perform network requests");
});
const { makeStorageStub } = require("./support/storageStub");
global.chrome = { storage: makeStorageStub(), runtime: { sendMessage() {} } };
// A stub DOM: every id resolves to a recording element.
const elements = new Map();
function makeEl(id) {
const handlers = new Map();
return {
id,
textContent: "",
innerHTML: "",
value: "",
disabled: false,
style: {},
dataset: {},
classList: {
add() {},
remove() {},
toggle() {},
contains: () => false,
},
handlers,
addEventListener(name, fn) {
handlers.set(name, fn);
},
querySelectorAll: () => [],
querySelector: () => null,
remove() {},
focus() {},
};
}
global.document = {
getElementById(id) {
if (!elements.has(id)) elements.set(id, makeEl(id));
return elements.get(id);
},
createElement: (tag) => makeEl(tag),
body: { prepend() {}, appendChild() {} },
addEventListener() {},
};
global.navigator = { clipboard: { writeText() {} } };
const { BaseWallet, Transaction, Wallet } = require("ethers");
const { encryptWithPassword } = require("../src/shared/vault");
const { state, loadState } = require("../src/shared/state");
const { onChainSwitch } = require("../src/shared/chainSwitch");
const { NETWORKS } = require("../src/shared/networks");
const txStatus = require("../src/popup/views/txStatus");
const confirmTx = require("../src/popup/views/confirmTx");
const PRIVATE_KEY = "0x" + "11".repeat(32);
const HOLDER = new Wallet(PRIVATE_KEY).address;
const RECIPIENT = "0xC0FfEE0000000000000000000000000000c0fFEe";
const PASSWORD = "correct horse battery staple";
function el(id) {
return global.document.getElementById(id);
}
let encryptedSecret;
let signTransaction;
beforeAll(async () => {
await loadState();
encryptedSecret = await encryptWithPassword(PRIVATE_KEY, PASSWORD);
});
beforeEach(() => {
elements.clear();
mockNode.broadcast = [];
txStatus.showWait.mockClear();
txStatus.showError.mockClear();
signTransaction = jest.spyOn(BaseWallet.prototype, "signTransaction");
});
afterEach(() => {
signTransaction.mockRestore();
});
// Show the confirmation screen for 1 of the native token on `networkId`, as
// Review on the Send screen does, with its fee estimate settled.
async function review(networkId) {
await onChainSwitch(networkId);
state.wallets = [
{
type: "key",
name: "Wallet 1",
encryptedSecret,
addresses: [{ address: HOLDER, balance: "5.0" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
confirmTx.init({});
confirmTx.show({
from: HOLDER,
to: RECIPIENT,
ensName: null,
amount: "1",
token: "ETH",
balance: "5.0",
tokenSymbol: null,
tokenBalance: null,
tokenDecimals: null,
max: false,
});
for (let i = 0; i < 10; i++) await new Promise((r) => setTimeout(r, 0));
}
async function signAndSend() {
el("confirm-tx-password").value = PASSWORD;
await el("btn-confirm-send").handlers.get("click")();
}
test("a transfer reviewed on Sepolia and sent goes out on Sepolia", async () => {
await review("sepolia");
expect(el("confirm-type").textContent).toBe("Native SepoliaETH transfer");
expect(el("btn-confirm-send").disabled).toBe(false);
await signAndSend();
expect(mockNode.broadcast).toHaveLength(1);
expect(mockNode.broadcast[0].chainId).toBe(11155111n);
expect(Transaction.from(mockNode.broadcast[0].signed).chainId).toBe(
11155111n,
);
expect(txStatus.showWait).toHaveBeenCalledTimes(1);
});
describe.each([
["sepolia", "mainnet", "SepoliaETH"],
["mainnet", "sepolia", "ETH"],
])(
"a transfer reviewed on %s, with %s chosen in Settings before Sign & Send",
(reviewedOn, switchedTo, symbol) => {
test("is neither signed nor sent, and the screen says why", async () => {
await review(reviewedOn);
expect(el("confirm-type").textContent).toBe(
"Native " + symbol + " transfer",
);
// Settings' network select, then Back, which only unhides the
// screen as it was drawn.
await onChainSwitch(switchedTo);
await signAndSend();
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
expect(txStatus.showWait).not.toHaveBeenCalled();
expect(txStatus.showError).not.toHaveBeenCalled();
expect(el("confirm-errors").style.visibility).toBe("visible");
expect(el("confirm-errors").innerHTML).toContain(
"The active network changed after this transaction was" +
" reviewed, so it was not sent. Please go back and review" +
" it again.",
);
});
},
);
// The check in the click handler tells the user; the send itself carries the
// chain id the screen was drawn for, so a signer on another network's node
// cannot sign it even if the network changes after that check.
test("the send is refused by a signer on another network's node", async () => {
const { getProvider } = require("../src/shared/balances");
const signer = new Wallet(PRIVATE_KEY).connect(
getProvider("https://rpc.example.invalid", "mainnet"),
);
await expect(
confirmTx.populateVerifyAndSend(signer, {
token: "ETH",
to: RECIPIENT,
amount: "1",
chainId: NETWORKS.sepolia.chainId,
}),
).rejects.toThrow("transaction chainId mismatch");
expect(signTransaction).not.toHaveBeenCalled();
expect(mockNode.broadcast).toEqual([]);
});
+13
View File
@@ -640,6 +640,19 @@ step(
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign");
// The window is opened as wide as the page
// (https://git.eeqj.de/sneak/AutistMask/issues/518). This suite is
// where that is checked: the Chrome suite's approval windows open at
// 1280x720 whatever width the extension asks for.
const sideways = await d.execute(
"return document.documentElement.scrollWidth -" +
" document.documentElement.clientWidth;",
);
assert(
sideways <= 0,
"the sign prompt scrolls sideways by " + sideways + "px",
);
const screen = await d.execute(
`return {
origin: document.getElementById("approve-sign-origin").textContent,
+13
View File
@@ -89,6 +89,12 @@ describe("vendored blocklist", () => {
expect(isPhishingDomain(LISTED[0].toUpperCase())).toBe(true);
});
test("a hostname ending in a dot is the same host", () => {
expect(isPhishingDomain(LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain("wallet." + LISTED[1] + ".")).toBe(true);
expect(isPhishingDomain(CLEAN[0] + ".")).toBe(false);
});
test("returns false for an empty or missing hostname", () => {
expect(isPhishingDomain("")).toBe(false);
expect(isPhishingDomain(null)).toBe(false);
@@ -133,6 +139,13 @@ describe("hostnameVariants", () => {
test("lowercases hostnames", () => {
expect(hostnameVariants("Evil.COM")).toEqual(["evil.com"]);
});
test("drops the trailing dot of a fully qualified hostname", () => {
expect(hostnameVariants("sub.evil.com.")).toEqual([
"sub.evil.com",
"evil.com",
]);
});
});
describe("domain hashing", () => {
+11
View File
@@ -242,6 +242,11 @@ async function settle() {
* "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the
* stub's own, for a test that has to answer the background's messages.
* @param {object} [options.windows] chrome.windows, for a test of the approval
* window resizing itself.
* @param {object} [options.documentElement] members of document.documentElement
* that replace the stub's own: scrollWidth and clientWidth, which are equal
* unless a test says otherwise, so the page does not scroll sideways.
* @returns {Promise<object>} handles onto the booted page.
*/
async function bootPopup(stored, options) {
@@ -277,6 +282,11 @@ async function bootPopup(stored, options) {
(options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML);
Object.assign(
document.documentElement,
{ scrollWidth: 396, clientWidth: 396 },
options && options.documentElement,
);
const reloads = [];
globalThis.chrome = {
@@ -287,6 +297,7 @@ async function bootPopup(stored, options) {
onMessage: { addListener: () => {} },
...(options && options.runtime),
},
windows: options && options.windows,
};
globalThis.document = document;
globalThis.window = {
+12
View File
@@ -153,6 +153,18 @@ describe("the explanatory copy", () => {
expect(all).not.toMatch(/\blost\b|\bstolen\b|\bgone\b/i);
});
// The wallet holds the imported key but will not export it, so the copy
// must name every key the user may hold that reaches the addresses
// outside it.
test("it names the keys that reach the addresses outside this wallet", () => {
const body = defect.paragraphs.join(" ");
expect(body).toMatch(/private key export are disabled/);
expect(body).toMatch(
/with the extended private key you imported, if you kept a copy of it, or with the master extended private key or recovery phrase it came from, in software that repeats the same non-standard derivation/,
);
});
test("it says the wallet is not deleted and what the user can do", () => {
const body = defect.paragraphs.join(" ");