harden: one connection and one signature prompt per site at a time (closes #405)
check / check (push) Failing after 1s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s

Each eth_requestAccounts or personal_sign call opened another approval
window, so a page calling in a loop could cover the screen with identical
prompts. While a site's connection or signature prompt is unanswered, a
further request of that kind from the same site is now refused with
EIP-1193 -32002 and opens no window; all signing methods count as one
kind. A connection prompt whose toolbar popup closed before it connected,
and which the toolbar popup no longer opens, is shown again by the site's
next request instead of refusing the site until the address changes.

Model: opus-5-5
This commit was merged in pull request #433.
This commit is contained in:
2026-10-04 19:43:11 +02:00
parent de3f7a9a11
commit d1751beb32
4 changed files with 379 additions and 20 deletions
+85 -15
View File
@@ -87,7 +87,8 @@ const pendingApprovals = {};
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals are not gated: a signature consumes no nonce.
// Sign approvals do not take this slot: a signature consumes no nonce. They are
// limited per site instead; see findPendingApproval().
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
@@ -99,7 +100,7 @@ let txApprovalSlot = null;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const TX_APPROVAL_PENDING_CODE = -32002;
const APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
@@ -136,6 +137,22 @@ function releaseTxApprovalSlotFor(approvalId) {
}
}
// One site-connection approval and one sign approval per site at a time: a
// page that asks again before the user has answered is refused with the code
// above instead of opening another window, so it cannot bury the user in
// prompts. The pending approval itself holds the place, so a caller must test
// this and raise its approval with nothing awaited in between.
function findPendingApproval(origin, type) {
return Object.values(pendingApprovals).find(
(approval) => approval.origin === origin && approval.type === type,
);
}
const APPROVAL_PENDING_MESSAGE =
"AutistMask is already waiting for your answer to a request of this kind" +
" from this site, so this one was not shown. Please answer that one," +
" then send this one again.";
// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
@@ -288,7 +305,12 @@ async function proxyRpc(method, params) {
return json.result;
}
// The site-connection approval the toolbar popup is set to open, or null while
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
let toolbarPopupApprovalId = null;
function resetPopupUrl() {
toolbarPopupApprovalId = null;
if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" });
}
@@ -466,26 +488,33 @@ async function openApprovalWindow(id) {
function requestApproval(origin) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, resolve };
pendingApprovals[id] = { id, origin, resolve, type: "site" };
if (actionNs && typeof actionNs.openPopup === "function") {
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionNs.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
} catch {
openApprovalWindow(id);
}
showInToolbarPopup(id);
} else {
openApprovalWindow(id);
}
});
}
// Show a site-connection approval in the toolbar popup, or in a separate popup
// window when the browser will not open the toolbar popup.
function showInToolbarPopup(id) {
toolbarPopupApprovalId = id;
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionNs.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
} catch {
openApprovalWindow(id);
}
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
// Uses windows.create() directly because tx approvals are triggered programmatically
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
@@ -641,6 +670,31 @@ async function handleConnectionRequest(origin) {
return { result: [activeAddress] };
}
const pending = findPendingApproval(origin, "site");
if (pending) {
// A toolbar popup that closed before it connected leaves its prompt
// pending, and once the toolbar popup is set to open something else
// nothing shows that prompt: the site would be refused until the
// address changed. Show it again. A prompt in a window or in a
// connected popup is settled when that closes, and one the toolbar
// popup is still set to open is a click away, so those are left alone.
if (
actionNs &&
typeof actionNs.openPopup === "function" &&
!pending.windowId &&
!pending.portConnected &&
toolbarPopupApprovalId !== pending.id
) {
showInToolbarPopup(pending.id);
}
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
// Open approval popup
const decision = await requestApproval(origin);
@@ -865,6 +919,14 @@ async function handleRpc(method, params, origin) {
"Only proceed if you fully understand what you are signing.";
}
if (findPendingApproval(origin, "sign")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestSignApproval(
origin,
signParams,
@@ -898,6 +960,14 @@ async function handleRpc(method, params, origin) {
},
};
}
if (findPendingApproval(origin, "sign")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestSignApproval(
origin,
signParams,
@@ -969,7 +1039,7 @@ async function handleSendTransaction(params, origin) {
if (!slot) {
return {
error: {
code: TX_APPROVAL_PENDING_CODE,
code: APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};