fix: a site allowed without Remember stays connected when the background worker idles (closes #526)
check / check (push) Waiting to run
e2e / e2e-chrome (push) Waiting to run
e2e / e2e-firefox (push) Waiting to run

A connection allowed without "Remember my choice for this site" was held
in a module-level object in the background worker. Chrome stops the worker
after about 30 seconds idle, so the connection ended at the first idle
spell. Each such connection is now one item in the browser's session
storage, which outlives the worker but not the browser, read and written
through new wrappers in src/shared/browserApi.js. Settings now waits for
the background to end a removed site's connections before listing the
connected sites again. The Firefox manifest declares 115.0, the first
version with session storage, as its minimum.

Model: opus-5-5
This commit is contained in:
2026-10-18 20:58:47 +00:00
parent 1f5ec1c8de
commit 234c6c4032
11 changed files with 572 additions and 93 deletions
+354
View File
@@ -0,0 +1,354 @@
// A site allowed without "Remember my choice for this site" stays connected
// when the background worker is stopped and started again
// (https://git.eeqj.de/sneak/AutistMask/issues/526).
//
// Chrome stops the worker after about 30 seconds idle and evaluates the
// module afresh on the next event, so nothing held at module scope survives
// it. Such a connection used to be held exactly there, and so lasted until the
// first idle spell. Each test here makes the connection in one load of the
// background, loads the module afresh over the same storage, as the browser
// does, and asks the new one. The paths that end a connection are driven after
// that restart, so each is shown to end what the earlier worker stored.
const { networkById } = require("../src/shared/networks");
const { makeStorageStub } = require("./support/storageStub");
const {
removeAddressFromState,
removeWalletFromState,
} = require("../src/shared/walletDelete");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const OTHER_ADDRESS = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const SITE = "https://dapp.example";
const OTHER_SITE = "https://other.example";
const EXT_URL = "chrome-extension://autistmask/";
const FROM_POPUP = { url: EXT_URL + "src/popup/index.html" };
// One wallet with two addresses, so that one of them can be removed, and no
// remembered sites, so that every connection here is one made without
// "Remember".
function storedProfile() {
const mainnet = networkById("mainnet");
return {
hasWallet: true,
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
{ address: OTHER_ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
networkId: "mainnet",
rpcUrl: mainnet.defaultRpcUrl,
blockscoutUrl: mainnet.defaultBlockscoutUrl,
allowedSites: {},
deniedSites: {},
trackedTokens: [],
};
}
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
jest.resetModules();
});
// Load the background module afresh, as the browser does when it starts the
// worker, over `storage`. The storage stub stands in for the browser's own
// storage, local and session, so it outlives each load.
function startWorker(storage) {
jest.resetModules();
jest.doMock("../src/shared/balances", () => ({
getProvider: () => ({}),
refreshBalances: jest.fn(async () => {}),
}));
jest.doMock("../src/shared/phishingDomains", () => ({
isPhishingDomain: () => false,
}));
jest.doMock("../src/shared/alarms", () => ({
BALANCE_REFRESH_ALARM: "balance",
BALANCE_REFRESH_PERIOD_MINUTES: 1,
ensureRecurringAlarms: jest.fn(async () => {}),
registerAlarmHandlers: jest.fn(),
}));
let messageListener = null;
let connectListener = null;
// Every URL the toolbar popup was set to open: a site's connection prompt
// is shown there, so this is where its approval id appears.
const toolbarPopups = [];
const windowsCreated = [];
const sentToTabs = [];
global.chrome = {
storage,
runtime: {
getURL: (path) => EXT_URL + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: {
addListener: (fn) => {
connectListener = fn;
},
},
getPlatformInfo: (cb) => cb({ os: "linux" }),
// What the popup sends reaches the worker from the extension's own
// page.
sendMessage: (msg, callback) => {
messageListener(msg, FROM_POPUP, callback || (() => {}));
},
lastError: null,
},
windows: {
getLastFocused: (cb) => cb(null),
create: (options, cb) => {
windowsCreated.push(options);
cb({ id: windowsCreated.length });
},
remove: (id, cb) => {
if (cb) cb();
},
onRemoved: { addListener: () => {} },
},
tabs: {
query: (q, cb) => cb([{ id: 1, url: SITE + "/app" }]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push(msg);
if (cb) cb();
},
},
action: {
setPopup: (o) => {
toolbarPopups.push(o.popup);
},
openPopup: () => Promise.resolve(),
},
};
require("../src/background/index");
// A request from a site, answered once the worker has had time to answer.
async function rpc(method, origin, params) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method, params: params || [] },
{ origin },
(r) => {
response = r;
},
);
await settle();
return response;
}
// A message from the popup, and the worker's answer to it.
async function fromPopup(msg) {
let response;
messageListener(msg, FROM_POPUP, (r) => {
response = r;
});
await settle();
return response;
}
// The site asks to connect, and the user allows it in the prompt with
// "Remember my choice for this site" left unchecked.
async function connectWithoutRemember(origin) {
let response = null;
messageListener(
{ type: "AUTISTMASK_RPC", method: "eth_requestAccounts" },
{ origin },
(r) => {
response = r;
},
);
await settle();
const id = new URL(toolbarPopups.at(-1), EXT_URL).searchParams.get(
"approval",
);
const decisionListeners = [];
const port = {
name: "approval:" + id,
sender: { url: EXT_URL + "src/popup/index.html?approval=" + id },
onMessage: { addListener: (fn) => decisionListeners.push(fn) },
onDisconnect: { addListener: () => {} },
};
connectListener(port);
for (const fn of decisionListeners) {
fn(
{
type: "AUTISTMASK_APPROVAL_DECISION",
approved: true,
remember: false,
},
port,
);
}
await settle();
return response;
}
return {
rpc,
fromPopup,
connectWithoutRemember,
windowsCreated,
sentToTabs,
};
}
// A site connected without "Remember" in one worker, and the worker started
// again over the same storage, where the site is still connected.
async function connectedThenRestarted() {
const storage = makeStorageStub({ autistmask: storedProfile() });
const first = startWorker(storage);
expect(await first.connectWithoutRemember(SITE)).toEqual({
result: [ADDRESS],
});
const worker = startWorker(storage);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
return { storage, worker };
}
describe("a site allowed without Remember, after the worker restarts", () => {
test("is still told the address, and is not asked again", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
expect(await worker.rpc("eth_requestAccounts", SITE)).toEqual({
result: [ADDRESS],
});
});
test("is still listed under Connected Sites in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([SITE]);
});
test("may still ask for a signature", async () => {
// Refused with 4100 before the fix. Allowed, it raises the signing
// prompt and waits on the user.
const { worker } = await connectedThenRestarted();
const response = await worker.rpc("personal_sign", SITE, [
"0x48656c6c6f",
ADDRESS,
]);
expect(response).toBeNull();
expect(worker.windowsCreated).toHaveLength(1);
});
test("is not connected after the browser restarts", async () => {
// The browser empties session storage when it closes; the wallet's
// own storage keeps nothing of the connection.
const { storage } = await connectedThenRestarted();
const afterBrowserRestart = startWorker(
makeStorageStub({ autistmask: storage.read("autistmask") }),
);
expect(await afterBrowserRestart.rpc("eth_accounts", SITE)).toEqual({
result: [],
});
});
});
describe("each way a connection allowed without Remember ends, after the worker restarts", () => {
test("removing the site in Settings", async () => {
const { worker } = await connectedThenRestarted();
expect(await worker.connectWithoutRemember(OTHER_SITE)).toEqual({
result: [ADDRESS],
});
await worker.fromPopup({
type: "AUTISTMASK_REMOVE_SITE",
origin: SITE,
});
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(worker.sentToTabs).toEqual([
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: [],
},
]);
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([OTHER_SITE]);
expect(await worker.rpc("eth_accounts", OTHER_SITE)).toEqual({
result: [ADDRESS],
});
});
test("switching to another address", async () => {
// Switched away and back again, so the answer turns on the connection
// having ended rather than on which address is active.
const { storage, worker } = await connectedThenRestarted();
const switched = { ...storage.read("autistmask") };
switched.activeAddress = OTHER_ADDRESS;
storage.write("autistmask", switched);
await worker.fromPopup({ type: "AUTISTMASK_ACTIVE_CHANGED" });
switched.activeAddress = ADDRESS;
storage.write("autistmask", switched);
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
expect(
await worker.fromPopup({ type: "AUTISTMASK_GET_CONNECTED_SITES" }),
).toEqual([]);
});
// The two below ask while storage still names the removed address as
// active, because the popup has not saved yet, so the answer turns on the
// connection alone.
test("removing the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 0).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing the wallet holding the connected address", async () => {
const { storage, worker } = await connectedThenRestarted();
removeWalletFromState(storage.read("autistmask"), 0);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({ result: [] });
});
test("removing a different address leaves it connected", async () => {
const { storage, worker } = await connectedThenRestarted();
const popupState = storage.read("autistmask");
expect(removeAddressFromState(popupState, 0, 1).removed).toBe(true);
await settle();
expect(await worker.rpc("eth_accounts", SITE)).toEqual({
result: [ADDRESS],
});
});
});