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
15 changed files with 217 additions and 583 deletions
+2 -2
View File
@@ -35,10 +35,10 @@ on: [push]
jobs: jobs:
e2e-chrome: e2e-chrome:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Bounds the image build, a cold cache included, and the three Chrome # Bounds the image build, a cold cache included, and both Chrome
# programs, so a hung browser frees the shared runner. README.md # programs, so a hung browser frees the shared runner. README.md
# "In CI" has the measured times. # "In CI" has the measured times.
timeout-minutes: 40 timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+15 -36
View File
@@ -464,6 +464,9 @@ papered over:
forced by clicking Reveal and the settings gear in one page task, which a 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 person cannot do; the case a person can hit, the first decrypt after the popup
opens while libsodium is still loading, is not driven. 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 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 is about to provoke, via `errors.expect()`. That is not a mute: the declaration
@@ -527,24 +530,6 @@ one profile yields a single extension id, not two: Chrome does not load a second
copy of an id it already has. The assertions are annotated as observations, so a copy of an id it already has. The assertions are annotated as observations, so a
Chrome that ever changes this fails the run instead of passing it. Chrome that ever changes this fails the run instead of passing it.
Last, `make test-e2e` runs `tests/e2e/slowApproval.js`, which leaves a
transaction approval and a signature approval open for seven minutes, then
approves both and requires the site to receive the hash of the transaction the
stub node received and a signature that recovers to the address. Before the
approval window kept the background worker running, Chrome stopped it about six
minutes in and neither request was ever answered
([#540](https://git.eeqj.de/sneak/AutistMask/issues/540)). It is its own program
and does not use Playwright: Playwright keeps DevTools attached to the
background worker, and Chrome never stops a worker DevTools is attached to, so
no wait in the suite above can show the worker stopping. It starts Chromium
itself, speaks the DevTools protocol over `--remote-debugging-pipe`, and
attaches to pages only. Before raising the approvals it requires the worker to
stop on its own with nothing pending, so a run in which something else keeps the
worker running fails. The site is connected and the RPC endpoint set through the
extension's own screens, against the Firefox suite's loopback dApp page and stub
node (`tests/e2e/firefox/dapp.js`). Unlike the suite above, it does not fail on
page errors.
### Firefox (`make test-e2e-firefox`) ### Firefox (`make test-e2e-firefox`)
`make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a `make test-e2e-firefox` builds `dist/firefox/` and drives the **real popup in a
@@ -554,7 +539,8 @@ suite), wallet creation through the UI, the Add Token screen, and the dApp round
trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain` trips — `eth_requestAccounts`, `personal_sign`, `wallet_switchEthereumChain`
rejected and then approved, `eth_sendTransaction`, and a closed approval window rejected and then approved, `eth_sendTransaction`, and a closed approval window
rejecting with EIP-1193 4001 — driven through the real content script, 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 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 **no npm dependencies at all**: it is built on global `fetch` and
@@ -725,13 +711,11 @@ installing Firefox, geckodriver and their system libraries. Both scripts now
build their image with `--no-cache`, so every run pays the cold figure. Those build their image with `--no-cache`, so every run pays the cold figure. Those
`e2e-chrome` runs predate the cases that wait in real time for a receipt to end `e2e-chrome` runs predate the cases that wait in real time for a receipt to end
in error. `make test-e2e` took 3m51s locally with its image cached, so with the in error. `make test-e2e` took 3m51s locally with its image cached, so with the
image rebuilt every run, an `e2e-chrome` run comes to about seven minutes. The image rebuilt every run, an `e2e-chrome` run comes to about seven minutes.
approvals left open for seven minutes (`tests/e2e/slowApproval.js`) add about
eight more, so about fifteen.
Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the Each e2e job has a `timeout-minutes` cap, so a hung build or browser ends the
job instead of holding the shared runner: `e2e-firefox` 15 minutes and job instead of holding the shared runner: `e2e-firefox` 15 minutes and
`e2e-chrome` 40, each over two and a half times the job's slowest cold run. A `e2e-chrome` 20, each over two and a half times the job's slowest cold run. A
job that reaches its cap has hung; read it as a hang, not as a slow run to job that reaches its cap has hung; read it as a hang, not as a slow run to
retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical retry. The `check` job has no cap: `.gitea/workflows/check.yml` is the canonical
copy from `sneak/prompts`, kept byte-identical. copy from `sneak/prompts`, kept byte-identical.
@@ -826,16 +810,6 @@ on the next event. Two consequences shape every recurring job in the background:
service worker at all — the one remaining user of it, `src/shared/ens.js`, service worker at all — the one remaining user of it, `src/shared/ens.js`,
runs only in the popup and is marked as such. runs only in the popup and is marked as such.
Pending approvals are the exception: an approval waiting for the user, and the
site's request waiting on it, live only in the worker's memory, and only the
worker that received the request can answer it. Chrome stopped the worker about
six minutes after a site's request even with the approval window still open, and
the user's answer then reached nobody. So while an approval window is open it
posts an empty message on its approval port every 20 seconds, and each message
is an event that keeps the worker running: an approval stays answerable however
long the user takes. A worker that stops for another reason, a crash say, still
loses the approvals open in it.
The job also carries a freshness guard, and a guard must never be timed to the The job also carries a freshness guard, and a guard must never be timed to the
alarm period it gates. The guard is measured from the moment the last run alarm period it gates. The guard is measured from the moment the last run
finished, which is one run-duration after the alarm that started it, so a guard finished, which is one run-duration after the alarm that started it, so a guard
@@ -923,8 +897,11 @@ discoverable.
enough that vanilla JS with simple view switching is sufficient. A framework 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 would add bundle size, build complexity, and attack surface for no benefit at
this scale. this scale.
- **360x600 popup**: Standard browser extension popup dimensions. The UI is - **396x600 popup**: The page is 396 pixels wide (`body` in
designed for this fixed viewport. `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 #### No Layout Shift
@@ -2105,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 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 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 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 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 transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with `eth_sendTransaction` arriving while one is unanswered is refused with
-12
View File
@@ -97,18 +97,6 @@ main() {
-e HOME=/tmp \ -e HOME=/tmp \
"$(cat "$IIDFILE")" \ "$(cat "$IIDFILE")" \
node tests/e2e/storagePartition.js node tests/e2e/storagePartition.js
# Approvals left open for seven minutes, past the point where Chrome
# stopped the background worker before the approval window kept it
# running. Its own program, without Playwright: Playwright keeps DevTools
# attached to the worker, and Chrome never stops a worker DevTools is
# attached to. It reaches nothing but its own loopback server.
echo "Running the approvals left open for seven minutes..."
docker run --rm \
--ipc=host \
-e HOME=/tmp \
"$(cat "$IIDFILE")" \
node tests/e2e/slowApproval.js
} }
main "$@" main "$@"
+5 -5
View File
@@ -423,7 +423,10 @@ function releaseApproval(approval) {
// the result on the floor, so a rejection here would be unhandled. // the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) { async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + 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; const popupHeight = 600;
let currentWin = null; let currentWin = null;
@@ -443,7 +446,7 @@ async function openApprovalWindow(id) {
// Centred on a browser window only. The last focused window can be // Centred on a browser window only. The last focused window can be
// another approval window still open, and centring on one can give a // another approval window still open, and centring on one can give a
// position the browser refuses ("Bounds must be at least 50% within // 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, // 1280x720. The request then failed with no window at all. Over a popup,
// the browser picks the position. // the browser picks the position.
if (currentWin && currentWin.type === "normal") { if (currentWin && currentWin.type === "normal") {
@@ -649,9 +652,6 @@ runtime.onConnect.addListener((port) => {
pendingApprovals[id].portConnected = true; pendingApprovals[id].portConnected = true;
} }
port.onMessage.addListener((msg) => { port.onMessage.addListener((msg) => {
// The window also posts an empty message every 20 seconds while
// it is open, only so that Chrome keeps this worker, and the
// approval in it, running (src/popup/views/approval.js, show()).
if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return; if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
if (!isExtensionSender(port.sender)) return; if (!isExtensionSender(port.sender)) return;
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
+17
View File
@@ -21,6 +21,7 @@ const {
goBack, goBack,
} = require("./views/helpers"); } = require("./views/helpers");
const { applyTheme } = require("./theme"); const { applyTheme } = require("./theme");
const { windowsGetCurrent, windowsUpdate } = require("../shared/browserApi");
// Renders a view the popup lands on without having navigated to it forward: // 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 // 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 // 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 params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval"); const approvalId = params.get("approval");
if (approvalId) { 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() // Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled // is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing // rejection rather than an uncaught error — measured as still failing
-16
View File
@@ -784,22 +784,6 @@ async function show(id) {
approvalId = id; approvalId = id;
approvalPort = runtimeApi().connect({ name: "approval:" + id }); approvalPort = runtimeApi().connect({ name: "approval:" + id });
// The approval this window answers, and the site's request waiting on it,
// exist only in the background worker's memory. Chrome stops that worker
// about six minutes after the site's request even while the user is still
// deciding, and the approval is lost with it. A message on this port is an
// event, and an event keeps the worker running, so one is sent every 20
// seconds for as long as this window is open. The background ignores it.
const port = approvalPort;
const timer = setInterval(() => {
try {
port.postMessage({});
} catch {
// The worker is gone anyway, and the port with it.
clearInterval(timer);
}
}, 20000);
let details = null; let details = null;
try { try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }); details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
+39 -12
View File
@@ -1,13 +1,14 @@
// Preparation of the transaction an approval screen displays. // Preparation of the transaction an approval screen displays.
// //
// A dApp's eth_sendTransaction normally fixes only `to`, `value` and `data`. // A dApp's eth_sendTransaction normally fixes `to`, `value` and `data`, and
// The nonce, the gas limit and the fees have to be filled in from the network // sometimes the gas limit. The nonce, the fees, and the gas limit when the
// before anything can be signed, and whoever fills them in decides what the // site gives none have to be filled in from the network before anything can
// user is shown. That work used to happen in the popup, after the user had // be signed, and whoever fills them in decides what the user is shown. That
// already approved: the numbers on the approval screen came from the popup and // work used to happen in the popup, after the user had already approved: the
// were compared against nothing, so a compromised popup could display one fee // numbers on the approval screen came from the popup and were compared against
// and sign another, and the ceilings in approvalVerify.js were all that stood // nothing, so a compromised popup could display one fee and sign another, and
// between the user and a fee that hands the validator the balance. // 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 // So it happens here instead, in the background, before the approval window is
// opened. The background populates the transaction, shows that object, and // opened. The background populates the transaction, shows that object, and
@@ -31,6 +32,7 @@ const {
VoidSigner, VoidSigner,
accessListify, accessListify,
getAddress, getAddress,
getBigInt,
getBytes, getBytes,
hexlify, hexlify,
toQuantity, toQuantity,
@@ -47,10 +49,11 @@ const {
// minutes long, which is not a wait anyone will sit through. // minutes long, which is not a wait anyone will sit through.
const POPULATE_TIMEOUT_MS = 20000; const POPULATE_TIMEOUT_MS = 20000;
// The request fields taken from the page. Anything else is dropped rather than // The request fields taken from the page, plus `gas`, which requestFrom()
// passed to ethers: the object is page-controlled, and a future ethers that // takes as the gas limit. Anything else is dropped rather than passed to
// learns to carry a new transaction field must not start picking one up out of // ethers: the object is page-controlled, and a future ethers that learns to
// it without this module knowing. // 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 // 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 // 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 !== ""; 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 // 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. // sentences even when the tail of one came from ethers or from the node.
function sentence(text) { function sentence(text) {
@@ -111,6 +124,20 @@ function requestFrom(txParams, from) {
for (const key of REQUEST_FIELDS) { for (const key of REQUEST_FIELDS) {
if (present(txParams[key])) request[key] = txParams[key]; 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 ( if (
present(request.type) && present(request.type) &&
!ALLOWED_TX_TYPES.includes(Number(request.type)) !ALLOWED_TX_TYPES.includes(Number(request.type))
+18
View File
@@ -250,6 +250,22 @@ function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused"); 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 * @param {number} windowId
* @returns {Promise<void>} * @returns {Promise<void>}
@@ -275,6 +291,8 @@ module.exports = {
tabsSendMessage, tabsSendMessage,
windowsApi, windowsApi,
windowsCreate, windowsCreate,
windowsGetCurrent,
windowsGetLastFocused, windowsGetLastFocused,
windowsRemove, windowsRemove,
windowsUpdate,
}; };
+47 -1
View File
@@ -9,7 +9,7 @@
// requesting page or the RPC node can say turns it into an approval that // requesting page or the RPC node can say turns it into an approval that
// should never have been raised. // should never have been raised.
const { Network, Wallet } = require("ethers"); const { Network, Transaction, Wallet, toQuantity } = require("ethers");
const { const {
prepareApprovalTx, prepareApprovalTx,
serializeApprovedTx, serializeApprovedTx,
@@ -138,6 +138,43 @@ describe("prepareApprovalTx", () => {
expect(approved.maxFeePerGas).toBe("0x12a05f200"); 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 () => { test("carries an access list the request asked for", async () => {
const approved = await prepareApprovalTx( const approved = await prepareApprovalTx(
providerWith(), providerWith(),
@@ -212,6 +249,15 @@ describe("prepareApprovalTx", () => {
).rejects.toThrow(/gas limit no network this wallet supports/); ).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 // 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 // under their own ceiling but multiply to thousands of ETH is refused
// before the approval window opens, so the user is never shown a // before the approval window opens, so the user is never shown a
-1
View File
@@ -58,7 +58,6 @@ const TX_PARAMS = {
to: RECIPIENT, to: RECIPIENT,
value: "0x2386f26fc10000", value: "0x2386f26fc10000",
data: "0xdeadbeef", data: "0xdeadbeef",
gas: "0x5208",
}; };
// The values populateTransaction() fills in when the dApp fixed none of them. // 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.visibleViews()).toEqual(["approve-network"]);
expect(env.pageErrors).toEqual([]); 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(); await settle();
expect(bg.created).toHaveLength(1); 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 () => { 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(); bg.requestSign();
await settle(); 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).toHaveLength(1);
expect(bg.created[0].left).toBeUndefined(); expect(bg.created[0].left).toBeUndefined();
expect(bg.created[0].top).toBeUndefined(); expect(bg.created[0].top).toBeUndefined();
@@ -2844,7 +2844,7 @@ describe("where an approval window opens", () => {
await settle(); await settle();
expect(bg.created).toHaveLength(2); 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].left).toBeUndefined();
expect(bg.created[1].top).toBeUndefined(); expect(bg.created[1].top).toBeUndefined();
+13
View File
@@ -640,6 +640,19 @@ step(
await d.switchToWindow(popup); await d.switchToWindow(popup);
await d.waitVisible("#view-approve-sign"); 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( const screen = await d.execute(
`return { `return {
origin: document.getElementById("approve-sign-origin").textContent, origin: document.getElementById("approve-sign-origin").textContent,
-495
View File
@@ -1,495 +0,0 @@
// A transaction approval and a signature approval the user leaves open for
// seven minutes are still answered, and the site receives both results
// (https://git.eeqj.de/sneak/AutistMask/issues/540).
//
// Both approvals, and the site's requests waiting on them, live only in the
// background worker's memory. With nothing else going on, Chrome stopped that
// worker about six minutes after the site's request, while the window was
// still open, and the user's answer then reached nobody. The approval window
// now posts on its approval port every 20 seconds, which keeps the worker
// running (src/popup/views/approval.js, show()).
//
// A separate program from run.js, and not driven by Playwright: Playwright
// keeps DevTools attached to the background worker, and Chrome never stops a
// worker DevTools is attached to, so in run.js the worker outlives any wait
// and a wait there proves nothing. This program starts Chromium itself and
// speaks the DevTools protocol over the pipe Chromium opens for it
// (--remote-debugging-pipe), attaching to pages only, never to the worker.
// Before the approvals are raised it watches the worker stop on its own, so a
// run in which something else keeps the worker running cannot pass.
//
// The dApp page and the stub node are the Firefox suite's loopback ones
// (tests/e2e/firefox/dapp.js). The site is connected and the node chosen
// through the extension's own screens, so nothing is written to its storage
// from outside.
//
// Run through script/test-e2e, which builds the extension and the pinned
// container.
//
// node tests/e2e/slowApproval.js
"use strict";
const fs = require("fs");
const os = require("os");
const path = require("path");
const { spawn } = require("child_process");
const { chromium } = require("playwright-core");
const {
Transaction,
getAddress,
getBytes,
hexlify,
parseEther,
toQuantity,
toUtf8Bytes,
verifyMessage,
} = require("ethers");
const { PASSWORD } = require("./harness");
const { STUB_COUNTERPARTY } = require("./network");
const { startDappServer } = require("./firefox/dapp");
const DIST_CHROME = path.resolve(__dirname, "..", "..", "dist", "chrome");
// Chrome stopped the worker six minutes after the site's request when nothing
// kept it running, so the approvals are left open for longer than that.
const APPROVAL_WAIT_MS = 7 * 60 * 1000;
// With nothing pending, Chrome stops the worker 30 seconds after its last
// event; this is how long the program waits to see that.
const IDLE_STOP_TIMEOUT_MS = 90000;
const SIGN_HEX = hexlify(toUtf8Bytes("AutistMask e2e: a slow signature"));
const checks = [];
let failed = 0;
function check(name, cond, detail) {
checks.push(name);
if (cond) {
console.log("ok " + checks.length + " - " + name);
} else {
failed += 1;
console.log("not ok " + checks.length + " - " + name);
if (detail) console.log(" " + detail);
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// Poll `probe` until it returns something truthy, and return that.
async function until(what, probe, timeout = 30000) {
const deadline = Date.now() + timeout;
for (;;) {
const value = await probe().catch(() => null);
if (value) return value;
if (Date.now() > deadline) {
throw new Error(
"gave up after " + timeout + "ms waiting for " + what,
);
}
await sleep(250);
}
}
// The DevTools protocol over Chromium's pipe: commands go to its fd 3, and
// replies and events come back on its fd 4, each one JSON message ended by a
// NUL byte. Events are not used and are dropped. Returns send(method, params,
// sessionId), which resolves with the command's result.
function devtools(browser) {
const waiting = new Map();
let nextId = 1;
let pending = "";
const replies = browser.stdio[4];
replies.setEncoding("utf8");
replies.on("data", (chunk) => {
pending += chunk;
let end;
while ((end = pending.indexOf("\0")) !== -1) {
const message = JSON.parse(pending.slice(0, end));
pending = pending.slice(end + 1);
const caller = waiting.get(message.id);
if (!caller) continue;
waiting.delete(message.id);
if (message.error) {
caller.reject(
new Error(caller.method + ": " + message.error.message),
);
} else {
caller.resolve(message.result);
}
}
});
replies.on("close", () => {
for (const caller of waiting.values()) {
caller.reject(new Error("the browser exited"));
}
waiting.clear();
});
return (method, params = {}, sessionId) =>
new Promise((resolve, reject) => {
const id = nextId++;
waiting.set(id, { method, resolve, reject });
browser.stdio[3].write(
JSON.stringify({ id, method, params, sessionId }) + "\0",
);
});
}
// Attach to a page and return a function that evaluates an expression in it,
// awaiting a promise it returns, and resolves with the value.
async function attach(send, targetId) {
const { sessionId } = await send("Target.attachToTarget", {
targetId,
flatten: true,
});
return async (expression) => {
const r = await send(
"Runtime.evaluate",
{ expression, awaitPromise: true, returnByValue: true },
sessionId,
);
if (r.exceptionDetails) {
throw new Error(
"evaluating " +
expression.slice(0, 80) +
" threw: " +
JSON.stringify(r.exceptionDetails.exception || {}),
);
}
return r.result.value;
};
}
// Whether the page has loaded and shows the element with this id: the
// `hidden` class showView() keeps on every other view is on neither it nor
// anything around it.
function shows(id) {
return (
"document.readyState === 'complete' &&" +
" !!document.getElementById(" +
JSON.stringify(id) +
") && !document.getElementById(" +
JSON.stringify(id) +
").closest('.hidden')"
);
}
// Set an input's value and fire the input event typing would fire.
function typeInto(id, value) {
return (
"(() => { const el = document.getElementById(" +
JSON.stringify(id) +
"); el.value = " +
JSON.stringify(value) +
"; el.dispatchEvent(new Event('input', { bubbles: true })); })()"
);
}
function click(id) {
return "document.getElementById(" + JSON.stringify(id) + ").click()";
}
// The outcome of a request the dApp page started with __dapp.start(), or
// {settled: "pending"} if it has not settled within `timeout`.
function settled(key, timeout = 30000) {
return (
"Promise.race([window.__dapp.settle(" +
JSON.stringify(key) +
"), new Promise((r) => setTimeout(() => r({ settled: 'pending' }), " +
timeout +
"))])"
);
}
async function main() {
if (!fs.existsSync(path.join(DIST_CHROME, "manifest.json"))) {
console.error(
"slowApproval: no unpacked build at " +
DIST_CHROME +
" — run make build first",
);
process.exitCode = 1;
return;
}
const node = await startDappServer();
const profile = fs.mkdtempSync(
path.join(os.tmpdir(), "autistmask-slow-approval-"),
);
const browser = spawn(
chromium.executablePath(),
[
"--headless",
// The container runs unprivileged, as in harness.js.
"--no-sandbox",
"--remote-debugging-pipe",
"--user-data-dir=" + profile,
"--disable-extensions-except=" + DIST_CHROME,
"--load-extension=" + DIST_CHROME,
// Nothing but the loopback dApp page and stub node can be reached.
"--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1",
"about:blank",
],
{ stdio: ["ignore", "ignore", "ignore", "pipe", "pipe"] },
);
try {
const send = devtools(browser);
const targets = async () =>
(await send("Target.getTargets")).targetInfos;
const worker = await until("the background worker", async () =>
(await targets()).find(
(t) =>
t.type === "service_worker" &&
t.url.startsWith("chrome-extension://"),
),
);
// The extension id is the host of the worker's URL, as in harness.js.
const popupUrl =
"chrome-extension://" +
new URL(worker.url).host +
"/src/popup/index.html";
const workerRunning = async () =>
(await targets()).some((t) => t.url === worker.url);
const openPage = async (url) => {
const { targetId } = await send("Target.createTarget", { url });
return { targetId, evaluate: await attach(send, targetId) };
};
const approvalPages = async () =>
(await targets()).filter(
(t) => t.type === "page" && t.url.includes("?approval="),
);
// A wallet, through the popup's own screens, and the stub node as its
// RPC endpoint, through Settings.
const popup = await openPage(popupUrl);
await until("the welcome screen", () =>
popup.evaluate(shows("view-welcome")),
);
await popup.evaluate(click("btn-welcome-add"));
await until("the add wallet screen", () =>
popup.evaluate(shows("view-add-wallet")),
);
await popup.evaluate(click("btn-generate-phrase"));
await until("a generated recovery phrase", () =>
popup.evaluate(
"document.getElementById('wallet-mnemonic').value.trim()" +
".split(/\\s+/).length >= 12",
),
);
await popup.evaluate(typeInto("add-wallet-password", PASSWORD));
await popup.evaluate(typeInto("add-wallet-password-confirm", PASSWORD));
await popup.evaluate(click("btn-add-wallet-confirm"));
await until(
"the main screen",
() => popup.evaluate(shows("view-main")),
60000,
);
await popup.evaluate(click("btn-settings"));
await until("the settings screen", () =>
popup.evaluate(shows("view-settings")),
);
await popup.evaluate(typeInto("settings-rpc", node.rpcUrl));
await popup.evaluate(click("btn-save-rpc"));
await until("the stub node saved as the RPC endpoint", () =>
popup.evaluate(
"new Promise((resolve) => chrome.storage.local.get(" +
"'autistmask', (r) => resolve(r.autistmask.rpcUrl === " +
JSON.stringify(node.rpcUrl) +
")))",
),
);
await send("Target.closeTarget", { targetId: popup.targetId });
// The site, connected through the real prompt with "Remember", so
// the connection is stored rather than held by the worker.
const dapp = await openPage(node.url);
await until("the injected provider", () =>
dapp.evaluate("!!window.ethereum && !!window.__dapp"),
);
await dapp.evaluate(
"window.__dapp.start('accounts', 'eth_requestAccounts', [])",
);
const prompt = await until(
"the connection prompt",
async () => (await approvalPages())[0],
);
const inPrompt = await attach(send, prompt.targetId);
await until("the connection prompt's screen", () =>
inPrompt(shows("view-approve-site")),
);
await inPrompt(
"document.getElementById('approve-remember').checked = true",
);
// The prompt closes itself on the click.
await inPrompt(click("btn-approve")).catch(() => {});
const accounts = await dapp.evaluate(settled("accounts"));
if (accounts.settled !== "resolved") {
throw new Error(
"the site could not be connected: " + JSON.stringify(accounts),
);
}
const address = getAddress(accounts.result[0]);
console.log("# address " + address + ", site " + node.origin);
// The control: with nothing pending, the worker stops on its own.
// Were something else keeping it running, the wait below would
// prove nothing.
const idleStart = Date.now();
const stoppedWhenIdle = await until(
"the idle worker to stop",
async () => !(await workerRunning()),
IDLE_STOP_TIMEOUT_MS,
).catch(() => false);
check(
"with nothing pending, Chrome stops the background worker on its own",
stoppedWhenIdle,
"the worker was still running " +
IDLE_STOP_TIMEOUT_MS / 1000 +
"s after its last event, so something other than the " +
"approval windows is keeping it running",
);
console.log(
"# idle worker stopped after " +
Math.round((Date.now() - idleStart) / 1000) +
"s",
);
// A transaction approval and a signature approval, both open at once.
await dapp.evaluate(
"window.__dapp.start('tx', 'eth_sendTransaction', [" +
JSON.stringify({
from: address,
to: STUB_COUNTERPARTY,
value: toQuantity(parseEther("0.01")),
}) +
"])",
);
await until("the transaction approval window", async () =>
(await approvalPages()).length === 1 ? true : null,
);
await dapp.evaluate(
"window.__dapp.start('sign', 'personal_sign', [" +
JSON.stringify(SIGN_HEX) +
", " +
JSON.stringify(address) +
"])",
);
const windows = await until("both approval windows", async () => {
const pages = await approvalPages();
return pages.length === 2 ? pages : null;
});
let txWindow = null;
let signWindow = null;
for (const page of windows) {
const evaluate = await attach(send, page.targetId);
const view = await until("an approval screen", () =>
evaluate(
"(" +
shows("view-approve-tx") +
") ? 'tx' : (" +
shows("view-approve-sign") +
") ? 'sign' : null",
),
);
if (view === "tx") txWindow = evaluate;
if (view === "sign") signWindow = evaluate;
}
if (!txWindow || !signWindow) {
throw new Error("the two windows are not one of each kind");
}
// The user takes seven minutes. The worker is checked every two
// seconds, and the wait ends early if Chrome stops it.
const opened = Date.now();
let stoppedAfter = null;
while (Date.now() - opened < APPROVAL_WAIT_MS) {
if (!(await workerRunning())) {
stoppedAfter = Math.round((Date.now() - opened) / 1000);
break;
}
await sleep(2000);
}
check(
"the background worker keeps running while both approvals are open",
stoppedAfter === null,
"Chrome stopped the worker " +
stoppedAfter +
"s after the approvals opened",
);
await txWindow(typeInto("approve-tx-password", PASSWORD));
await txWindow(click("btn-approve-tx"));
await signWindow(typeInto("approve-sign-password", PASSWORD));
// The window closes itself once the signature is handed over.
await signWindow(click("btn-approve-sign")).catch(() => {});
const tx = await dapp.evaluate(settled("tx"));
const broadcast = node.broadcast.map((raw) => Transaction.from(raw));
check(
"the transaction approved after seven minutes reaches the site",
tx.settled === "resolved" &&
broadcast.length === 1 &&
tx.result === broadcast[0].hash &&
getAddress(broadcast[0].from) === address,
"the page got " +
JSON.stringify(tx) +
" and the node received " +
broadcast.length +
" transaction(s)",
);
const sign = await dapp.evaluate(settled("sign"));
check(
"the signature approved after seven minutes reaches the site",
sign.settled === "resolved" &&
getAddress(verifyMessage(getBytes(SIGN_HEX), sign.result)) ===
address,
"the page got " + JSON.stringify(sign),
);
} catch (e) {
failed += 1;
console.log("# ERROR: " + (e && e.stack ? e.stack : e));
} finally {
if (browser.exitCode === null && browser.signalCode === null) {
const exited = new Promise((resolve) =>
browser.once("exit", resolve),
);
browser.kill();
await exited;
}
await node.close();
// Chromium's helper processes can still be writing to the profile
// for a moment after the browser process itself has exited.
fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5 });
}
console.log("1.." + checks.length);
if (checks.length === 0) {
console.log("# FAILED: this program asserted nothing");
process.exitCode = 1;
return;
}
console.log(
"# " +
(checks.length - failed) +
"/" +
checks.length +
" checks passed",
);
if (failed > 0) {
console.log("# FAILED");
process.exitCode = 1;
}
}
main().catch((e) => {
console.error("slowApproval: " + (e && e.stack ? e.stack : e));
process.exitCode = 1;
});
+11
View File
@@ -242,6 +242,11 @@ async function settle() {
* "?approval=" and an id for the popup opened as an approval window. * "?approval=" and an id for the popup opened as an approval window.
* @param {object} [options.runtime] members of chrome.runtime that replace the * @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. * 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. * @returns {Promise<object>} handles onto the booted page.
*/ */
async function bootPopup(stored, options) { async function bootPopup(stored, options) {
@@ -277,6 +282,11 @@ async function bootPopup(stored, options) {
(options && options.storage) || (options && options.storage) ||
makeStorageStub(stored === undefined ? {} : { autistmask: stored }); makeStorageStub(stored === undefined ? {} : { autistmask: stored });
const document = makeDocument(POPUP_HTML); const document = makeDocument(POPUP_HTML);
Object.assign(
document.documentElement,
{ scrollWidth: 396, clientWidth: 396 },
options && options.documentElement,
);
const reloads = []; const reloads = [];
globalThis.chrome = { globalThis.chrome = {
@@ -287,6 +297,7 @@ async function bootPopup(stored, options) {
onMessage: { addListener: () => {} }, onMessage: { addListener: () => {} },
...(options && options.runtime), ...(options && options.runtime),
}, },
windows: options && options.windows,
}; };
globalThis.document = document; globalThis.document = document;
globalThis.window = { globalThis.window = {