fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 38s
e2e / e2e-chrome (push) Successful in 48s
e2e / e2e-firefox (push) Successful in 23s

This commit was merged in pull request #289.
This commit is contained in:
2026-08-17 09:34:07 +02:00
parent 7690fe6429
commit 8fcdd8a053
5 changed files with 509 additions and 71 deletions

View File

@@ -494,13 +494,53 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
});
}
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the
// windows.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
// Anything only the extension's own pages may say. A content script speaks
// with the page's URL, so this is what separates the popup from the site the
// popup is being asked about.
function isExtensionSender(sender) {
const extUrl = runtime.getURL("");
return !!(sender && sender.url && sender.url.startsWith(extUrl));
}
// The approval popup's port: it carries the user's decision on a
// site-connection approval, and its disconnect is how that approval learns the
// popup closed without one.
//
// The decision travels this port rather than a one-off runtime.sendMessage()
// for exactly one reason: the port is also what the popup's window.close()
// disconnects. A message posted on a port is delivered before that port's
// disconnect, so approve-then-close settles as an approval no matter how fast
// the teardown is. Sent as a one-off message the two crossed on independent
// channels with nothing ordering them, and the teardown won every time when
// the prompt was driven in a tab: the user approved and the dApp was told they
// had refused.
//
// TX and sign approvals do not decide here. They stay pending across a
// disconnect — the user can reopen the toolbar popup — and are rejected by the
// windows.onRemoved listener below.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
const id = port.name.split(":")[1];
if (pendingApprovals[id] && isExtensionSender(port.sender)) {
// The extension's own popup is on the other end, so its disconnect
// is a trustworthy "closed" and onRemoved below stands down. The
// sender check is what keeps that from being an off switch: a
// content script that guessed the id and held its port open would
// otherwise disable the only settlement path a prompt whose popup
// never connected has left, and the dApp would wait forever.
pendingApprovals[id].portConnected = true;
}
port.onMessage.addListener((msg) => {
if (!msg || msg.type !== "AUTISTMASK_APPROVAL_DECISION") return;
if (!isExtensionSender(port.sender)) return;
const approval = pendingApprovals[id];
if (!approval || approval.type === "tx" || approval.type === "sign")
return;
settleApproval(id, {
approved: !!msg.approved,
remember: !!msg.remember,
});
});
port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id];
if (approval) {
@@ -510,7 +550,6 @@ runtime.onConnect.addListener((port) => {
}
settleApproval(id, { approved: false, remember: false });
}
resetPopupUrl();
});
}
});
@@ -1074,10 +1113,21 @@ startBackgroundJobs();
// outcome to the page — and the window is recorded as gone, so that an attempt
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
//
// A site-connection approval whose popup connected its port is not decided
// here. That popup approves and closes in the same breath, and this event
// races the decision on a channel of its own — the same race the port exists
// to end. Its port disconnect says the same thing this event does, in an order
// that is defined, so the disconnect is left to say it. The window closing
// before any port connected is the one case with nothing else to speak for it,
// and is rejected here so the dApp is not left waiting on a window that is
// gone.
if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue;
const isSite = approval.type !== "tx" && approval.type !== "sign";
if (isSite && approval.portConnected) continue;
const rejection = abandonedResult(
approval,
APPROVAL_REJECTED_CODE,
@@ -1127,18 +1177,16 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
}
// Validate that popup-only messages originate from the extension itself.
// The site-connection decision is not here: it is a port message, and it
// is checked the same way where the port is served.
const POPUP_ONLY_TYPES = [
"AUTISTMASK_GET_APPROVAL",
"AUTISTMASK_APPROVAL_RESPONSE",
"AUTISTMASK_TX_RESPONSE",
"AUTISTMASK_SIGN_RESPONSE",
];
if (POPUP_ONLY_TYPES.includes(msg.type)) {
const extUrl = runtime.getURL("");
if (!sender.url || !sender.url.startsWith(extUrl)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
if (POPUP_ONLY_TYPES.includes(msg.type) && !isExtensionSender(sender)) {
sendResponse({ error: "Unauthorized sender" });
return false;
}
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
@@ -1170,15 +1218,6 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
return false;
}
if (msg.type === "AUTISTMASK_APPROVAL_RESPONSE") {
settleApproval(msg.id, {
approved: msg.approved,
remember: msg.remember,
});
resetPopupUrl();
return false;
}
if (msg.type === "AUTISTMASK_TX_RESPONSE") {
const approval = pendingApprovals[msg.id];
if (!approval) return false;

View File

@@ -443,7 +443,7 @@ function showSignApproval(details) {
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id;
runtimeApi().connect({ name: "approval:" + id });
approvalPort = runtimeApi().connect({ name: "approval:" + id });
let details = null;
try {
@@ -476,6 +476,14 @@ async function show(id) {
}
let approvalId = null;
// The port this approval was opened on. Closing this window disconnects it,
// and the background treats that disconnect as "closed without deciding" for a
// site connection — so the decision goes out on this same port and not as a
// one-off message. One channel is ordered: a message posted on it is delivered
// before its own disconnect, however immediately the close follows. Two
// channels were not, and the close won, reporting a user who approved as
// having refused.
let approvalPort = null;
let pendingTxDetails = null;
// The exact objects shown to the user, kept so the popup signs what it
// displayed rather than re-fetching or re-populating anything at approval
@@ -543,6 +551,28 @@ function clearSignPassword() {
hideError("approve-sign-error");
}
// Answer a site-connection approval and close. The decision goes out on the
// approval port — see approvalPort above for why — and carries no approval id,
// because the port name already names the approval the background will settle.
// The post is guarded because a throw must not cost the close: posting on a
// port whose background worker has been torn down throws, and the approval it
// would have settled died with that worker, so the only thing left to do is
// what the user asked for — go away.
function decideSite(approved) {
if (approvalPort) {
try {
approvalPort.postMessage({
type: "AUTISTMASK_APPROVAL_DECISION",
approved,
remember: $("approve-remember").checked,
});
} catch {
// Nothing to report it to; the window closes either way.
}
}
window.close();
}
function init(_ctx) {
onViewLeave("approve-tx", clearTxPassword);
onViewLeave("approve-sign", clearSignPassword);
@@ -553,25 +583,11 @@ function init(_ctx) {
});
$("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked;
notify({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
remember,
});
window.close();
decideSite(true);
});
$("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked;
notify({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
remember,
});
window.close();
decideSite(false);
});
$("btn-approve-tx").addEventListener("click", async () => {