Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01dce26807 | ||
|
|
2442441830 |
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user