Compare commits

..
Author SHA1 Message Date
sneak 969a40e898 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 05:35:41 +00:00
9 changed files with 93 additions and 98 deletions
+4 -7
View File
@@ -2071,7 +2071,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
@@ -2124,12 +2126,7 @@ view would leave a wallet one click from deletion.
blocklist)
- Site origin (bold, scheme and port included) + "wants you to sign a
message"
- Warning box, shown for `eth_sign`: "This site used the older eth_sign
method. The data will be signed as a personal message." An `eth_sign`
request names the address first and the data second. Its data is signed
exactly as a `personal_sign` message is, as an EIP-191 personal message,
which is what the Ethereum JSON-RPC specification defines `eth_sign` to
return.
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
- Type: "Personal message" or "Typed data (EIP-712)"
- From: color dot + full address + etherscan link
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
+3 -2
View File
@@ -984,8 +984,9 @@ async function handleRpc(method, params, origin) {
if (method === "eth_sign") {
signParams.dangerWarning =
"This site used the older eth_sign method. " +
"The data will be signed as a personal message.";
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
}
if (findPendingApproval(origin, "sign")) {
+8 -12
View File
@@ -7,10 +7,9 @@
<link rel="stylesheet" href="styles.css" />
</head>
<!-- Chrome gives extension pages a stylesheet of its own that sets the
font and its size on body, and a Tailwind class beats it only when
marked important: hence font-mono! and text-sm! rather than
font-mono and text-sm. -->
<body class="bg-bg text-fg font-mono! text-sm!">
font on body, and a Tailwind class beats it only when marked
important: hence font-mono! rather than font-mono. -->
<body class="bg-bg text-fg font-mono! text-sm">
<div id="app" class="p-2 pr-5 overflow-x-hidden">
<!-- ============ GLOBAL TITLE BAR ============ -->
<div
@@ -88,14 +87,11 @@
<!-- Mnemonic form section -->
<div id="add-wallet-section-mnemonic">
<!-- The die button shares the instruction's row: on a row
of its own it pushes Import below the fold at
360x600. -->
<div class="mb-2 flex justify-between items-start gap-2">
<p>
Enter your 12 or 24 word recovery phrase below, or
click the button to roll the die for a new one.
</p>
<p class="mb-2">
Enter your 12 or 24 word recovery phrase below, or click
the button to roll the die for a new one.
</p>
<div class="mb-1 flex justify-end">
<button
id="btn-generate-phrase"
class="border border-border px-2 py-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"
+1 -1
View File
@@ -721,7 +721,7 @@ function showSignApproval(details) {
"This message is plain text, not hex, so it cannot be signed.";
}
// The warning the background attaches to an eth_sign request.
// Display danger warning for eth_sign (raw hash signing)
const warningEl = $("approve-sign-danger-warning");
if (warningEl) {
if (sp.dangerWarning) {
+30 -4
View File
@@ -31,6 +31,7 @@ const {
VoidSigner,
accessListify,
getAddress,
getBigInt,
getBytes,
hexlify,
toQuantity,
@@ -47,10 +48,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 +86,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 +123,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))
+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
-47
View File
@@ -1551,53 +1551,6 @@ describe("what the approval is verified against", () => {
});
});
// eth_sign names the address first and the data second, the reverse of
// personal_sign, and is answered as the JSON-RPC specification defines it:
// with an EIP-191 personal message signature over the data, the same
// signature personal_sign returns
// (https://git.eeqj.de/sneak/AutistMask/issues/409).
describe("eth_sign", () => {
test("is read address first, and returns a personal message signature over the data", async () => {
const bg = loadBackground();
const page = bg.send(
{
type: "AUTISTMASK_RPC",
method: "eth_sign",
params: [signer.address, MESSAGE],
},
{ origin: ORIGIN },
);
await settle();
const id = approvalIdIn(bg.created.map((c) => c.url));
const details = bg.send(
{ type: "AUTISTMASK_GET_APPROVAL", id },
{ url: bg.fromPopup.url },
);
const { signParams } = details.sendResponse.mock.calls[0][0];
expect(signParams).toMatchObject({
method: "eth_sign",
message: MESSAGE,
from: signer.address,
});
expect(signParams.dangerWarning).toBe(
"This site used the older eth_sign method. " +
"The data will be signed as a personal message.",
);
const signature = await signer.signMessage(
Buffer.from(MESSAGE.slice(2), "hex"),
);
bg.send(
{ type: "AUTISTMASK_SIGN_RESPONSE", id, approved: true, signature },
{ url: bg.fromPopup.url },
);
await settle();
expect(page.sendResponse).toHaveBeenCalledWith({ result: signature });
});
});
// The interlock must not cost the retry the approval exists to allow.
describe("the interlock releases a failed attempt", () => {
test("a retryable failure before the broadcast leaves the approval usable", async () => {
-12
View File
@@ -128,18 +128,6 @@ step("the popup is drawn in the monospace font it declares", async (env) => {
);
});
// The same check as the Chrome suite's (#456), so both browsers are held to
// the same size.
step("the popup's text is drawn at the 14px text-sm declares", async (env) => {
const size = await env.driver.execute(
"return getComputedStyle(document.body).fontSize;",
);
assert(
size === "14px",
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
);
});
// The recovery screen (#361): the Chrome suite's four cases, run before any
// wallet exists for the same reason. With no wallet nothing saves on a timer,
// so no save can write a good record over the unreadable one. The last of them
-12
View File
@@ -113,18 +113,6 @@ test("the popup is drawn in the monospace font it declares (#418)", async (env)
);
});
// The same stylesheet sets the size on body to 75%, which would draw the
// popup at 12px.
test("the popup's text is drawn at the 14px text-sm declares (#456)", async (env) => {
const size = await env.page.evaluate(
() => getComputedStyle(document.body).fontSize,
);
assert(
size === "14px",
"the popup's text is drawn at " + size + ", not at the 14px of text-sm",
);
});
// The empirical half of #182. The manifest change is only a claim about
// what the CSP permits; this is the observation. Two things have to hold
// together, and the run covers both: the popup realm compiles WASM (here),