fix: settle a site approval on the port that carries its teardown (closes #275)
All checks were successful
check / check (push) Successful in 30s
All checks were successful
check / check (push) Successful in 30s
Approve and window.close() left the popup on the next line, and the decision and the disconnect the close caused travelled independent channels with nothing ordering them. The disconnect handler settled a pending site approval as a rejection, so whichever landed first decided the outcome. Driven in a tab the teardown won every time: the user allowed the connection and the dApp was told they had refused. The decision now goes out on the approval port the popup already opens, which is the same port the close disconnects. One channel is ordered -- a message posted on a port is delivered before that port's own disconnect -- so the approval is settled before the teardown is even seen, and the disconnect then finds nothing pending to reject. Nothing waits, nothing is timed, and the popup closes exactly as immediately as before. windows.onRemoved no longer decides a site approval whose port is connected either. In the fallback-window shape that event races the decision on a channel of its own, which is the same defect one level over; the port disconnect says the same thing in a defined order, so it is left to say it. A window that closes before its popup ever connected has nothing else to speak for it and is still rejected there, so no dApp is left waiting on a window that is gone. Rejecting reports a rejection, and so does closing without deciding, in both shapes. AUTISTMASK_APPROVAL_RESPONSE is gone; the port name carries the approval id, so the popup no longer names one, and the sender check the message carried moved to the port. tests/backgroundApproval.test.js drives decide-then-disconnect with nothing awaited in between, in the toolbar-popup shape that production uses and in the fallback-window shape, and asserts every close-without-deciding path still rejects. tests/e2e/run.js drops the deferred-window.close() accommodation it carried for this bug, so the two site-prompt tests now drive the shipped decide-then-close in a real Chromium.
This commit is contained in:
@@ -279,13 +279,50 @@ 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
|
||||
// windowsApi.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
|
||||
// windowsApi.onRemoved listener below.
|
||||
runtime.onConnect.addListener((port) => {
|
||||
if (port.name.startsWith("approval:")) {
|
||||
const id = port.name.split(":")[1];
|
||||
if (pendingApprovals[id]) {
|
||||
// This approval has a popup that can speak for it, so its
|
||||
// disconnect is a trustworthy "closed"; see onRemoved below.
|
||||
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,
|
||||
});
|
||||
resetPopupUrl();
|
||||
});
|
||||
port.onDisconnect.addListener(() => {
|
||||
const approval = pendingApprovals[id];
|
||||
if (approval) {
|
||||
@@ -832,20 +869,32 @@ startBackgroundJobs();
|
||||
// window is an ordinary event with an attempt already in flight behind it.
|
||||
// settleApproval() refuses those, which leaves the attempt to report its real
|
||||
// outcome to the page.
|
||||
//
|
||||
// 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 (windowsApi && windowsApi.onRemoved) {
|
||||
windowsApi.onRemoved.addListener((windowId) => {
|
||||
for (const [id, approval] of Object.entries(pendingApprovals)) {
|
||||
if (approval.windowId !== windowId) continue;
|
||||
const rejection =
|
||||
approval.type === "tx" || approval.type === "sign"
|
||||
? {
|
||||
const isSite = approval.type !== "tx" && approval.type !== "sign";
|
||||
if (isSite && approval.portConnected) continue;
|
||||
settleApproval(
|
||||
id,
|
||||
isSite
|
||||
? { approved: false, remember: false }
|
||||
: {
|
||||
error: {
|
||||
code: 4001,
|
||||
message: "User rejected the request.",
|
||||
},
|
||||
}
|
||||
: { approved: false, remember: false };
|
||||
settleApproval(id, rejection);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -872,18 +921,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") {
|
||||
@@ -915,15 +962,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;
|
||||
|
||||
@@ -441,7 +441,7 @@ function showSignApproval(details) {
|
||||
|
||||
function show(id) {
|
||||
approvalId = id;
|
||||
runtime.connect({ name: "approval:" + id });
|
||||
approvalPort = runtime.connect({ name: "approval:" + id });
|
||||
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
|
||||
if (!details) {
|
||||
window.close();
|
||||
@@ -470,6 +470,14 @@ 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
|
||||
@@ -537,6 +545,20 @@ 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.
|
||||
function decideSite(approved) {
|
||||
if (approvalPort) {
|
||||
approvalPort.postMessage({
|
||||
type: "AUTISTMASK_APPROVAL_DECISION",
|
||||
approved,
|
||||
remember: $("approve-remember").checked,
|
||||
});
|
||||
}
|
||||
window.close();
|
||||
}
|
||||
|
||||
function init(ctx) {
|
||||
onViewLeave("approve-tx", clearTxPassword);
|
||||
onViewLeave("approve-sign", clearSignPassword);
|
||||
@@ -547,25 +569,11 @@ function init(ctx) {
|
||||
});
|
||||
|
||||
$("btn-approve").addEventListener("click", () => {
|
||||
const remember = $("approve-remember").checked;
|
||||
runtime.sendMessage({
|
||||
type: "AUTISTMASK_APPROVAL_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
remember,
|
||||
});
|
||||
window.close();
|
||||
decideSite(true);
|
||||
});
|
||||
|
||||
$("btn-reject").addEventListener("click", () => {
|
||||
const remember = $("approve-remember").checked;
|
||||
runtime.sendMessage({
|
||||
type: "AUTISTMASK_APPROVAL_RESPONSE",
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
remember,
|
||||
});
|
||||
window.close();
|
||||
decideSite(false);
|
||||
});
|
||||
|
||||
$("btn-approve-tx").addEventListener("click", async () => {
|
||||
|
||||
Reference in New Issue
Block a user