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
11 changed files with 213 additions and 23 deletions
+13 -4
View File
@@ -464,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
@@ -536,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
@@ -893,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
@@ -2075,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
+5 -2
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") {
+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
+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,
};
+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();
+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,
+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 = {