harden: key remembered site permissions by full origin (closes #402)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 1s

allowedSites and deniedSites held the bare hostname, so a grant to
https://dapp.example also authorised http://dapp.example and every port
on that host, and the connection, transaction and signature prompts
named only the hostname. Both lists now store and match the full origin
(scheme://host[:port]), the key the connections approved without
Remember already used. The prompts, the Settings site lists and
AUTISTMASK_REMOVE_SITE use the origin too. Entries saved by hostname
are not migrated (pre-1.0): they match no site.

Model: opus-5-5
This commit was merged in pull request #431.
This commit is contained in:
2026-10-04 18:09:04 +02:00
parent f24b5bca19
commit 1144fdb71b
29 changed files with 470 additions and 231 deletions
+85 -17
View File
@@ -39,7 +39,6 @@ const other = new Wallet(OTHER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ORIGIN = "https://dapp.example";
const HOSTNAME = "dapp.example";
// A page the wallet has never been connected to, whose requests are refused.
const UNCONNECTED_ORIGIN = "https://stranger.example";
const EXT_URL = "chrome-extension://autistmask/";
@@ -199,7 +198,7 @@ function loadBackground(options) {
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [HOSTNAME] },
allowedSites: { [signer.address]: [ORIGIN] },
deniedSites: {},
};
@@ -2193,12 +2192,54 @@ describe("removing an address ends a site's connection to it", () => {
});
});
// A remembered permission belongs to the origin it was granted to, scheme and
// port included (https://git.eeqj.de/sneak/AutistMask/issues/402). The stored
// state allows ORIGIN, https://dapp.example. A cleartext page on the same host,
// which a network attacker can serve, and another port on it are other sites.
describe("a remembered permission is held by the full origin", () => {
for (const origin of ["http://dapp.example", "https://dapp.example:8443"]) {
test(`an https grant does not authorise ${origin}`, async () => {
const bg = loadBackground();
expect(await siteAccounts(bg, ORIGIN)).toEqual({
result: [signer.address],
});
expect(await siteAccounts(bg, origin)).toEqual({ result: [] });
const send = bg.requestTx(TX_PARAMS, origin);
await settle();
expect(send.result()).toEqual({
error: { code: 4100, message: "Unauthorized" },
});
expect(send.id()).toBeNull();
});
}
test("Remember stores the origin, so the cleartext page on that host is asked again", async () => {
const bg = loadBackground({ actionPopup: true });
const granted = bg.requestSite(FRESH_ORIGIN);
await settle();
const grantedId = granted.id();
bg.connectApproval(grantedId).decide(true, true);
await settle();
expect(granted.result()).toEqual({ result: [signer.address] });
expect(
bg.storage.read("autistmask").allowedSites[signer.address],
).toEqual([ORIGIN, FRESH_ORIGIN]);
const cleartext = bg.requestSite("http://fresh.example");
await settle();
// Unanswered: it is waiting on a prompt of its own.
expect(cleartext.result()).toBeNull();
expect(cleartext.id()).not.toBe(grantedId);
});
});
// Settings lists the sites allowed without "Remember", which only the
// background holds, and removing a site there, from either list, disconnects
// it. These drive the real Settings view against the real background and
// click the [x] the user clicks.
describe("removing a site in Settings disconnects it", () => {
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
// The host of FRESH_ORIGIN on another port, which makes it another site.
const FRESH_OTHER_PORT = "https://fresh.example:8443";
// A site list's container. Its [x] buttons, data attributes and all, are
@@ -2226,9 +2267,9 @@ describe("removing a site in Settings disconnects it", () => {
return list;
}
// The hostnames a site list shows.
// The origins a site list shows.
function listed(list) {
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/g)].map(
(match) => match[1],
);
}
@@ -2259,10 +2300,10 @@ describe("removing a site in Settings disconnects it", () => {
allowed: element("settings-allowed-sites"),
connected: element("settings-connected-sites"),
// Click the [x] beside a site, and let what it sends run.
remove: async (list, hostname) => {
expect(listed(list)).toContain(hostname);
remove: async (list, origin) => {
expect(listed(list)).toContain(origin);
const button = list.buttons.find(
(b) => b.dataset.hostname === hostname,
(b) => b.dataset.origin === origin,
);
await button.click();
await settle();
@@ -2274,14 +2315,15 @@ describe("removing a site in Settings disconnects it", () => {
delete global.document;
});
test("Settings lists a site connected without Remember", async () => {
test("Settings lists each site by its origin", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
expect(listed(settings.connected)).toEqual(["fresh.example"]);
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
expect(listed(settings.connected)).toEqual([FRESH_ORIGIN]);
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
});
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
@@ -2293,6 +2335,7 @@ describe("removing a site in Settings disconnects it", () => {
cb([
{ id: 1, url: FRESH_ORIGIN + "/app" },
{ id: 2, url: ORIGIN + "/app" },
{ id: 3, url: FRESH_OTHER_PORT + "/app" },
]),
sendMessage: (tabId, msg, cb) => {
sentToTabs.push({ tabId, msg });
@@ -2301,7 +2344,7 @@ describe("removing a site in Settings disconnects it", () => {
};
const settings = await openSettings(bg);
await settings.remove(settings.connected, "fresh.example");
await settings.remove(settings.connected, FRESH_ORIGIN);
expect(await siteAccounts(bg)).toEqual({ result: [] });
expect(sentToTabs).toEqual([
@@ -2321,20 +2364,45 @@ describe("removing a site in Settings disconnects it", () => {
});
});
// The same hostname can hold both kinds of connection: one origin allowed
// without Remember, then another, on a different port, allowed with it.
// One origin can hold both kinds of connection under two addresses:
// remembered for one, allowed without Remember for the other.
test("removing a remembered site also ends its connection made without Remember", async () => {
const bg = loadBackground({ actionPopup: true });
const stored = bg.storage.read("autistmask");
stored.wallets[0].addresses.push({
address: other.address,
balance: "0",
tokenBalances: [],
});
bg.storage.write("autistmask", stored);
await connect(bg, FRESH_ORIGIN, true);
bg.setActiveAddress(other.address);
const pending = bg.requestSite(FRESH_ORIGIN);
await settle();
bg.connectApproval(pending.id()).decide(true, false);
await settle();
expect(pending.result()).toEqual({ result: [other.address] });
const settings = await openSettings(bg);
await settings.remove(settings.allowed, FRESH_ORIGIN);
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
});
test("removing a remembered site leaves the same host on another port connected", async () => {
const bg = loadBackground({ actionPopup: true });
await connect(bg, FRESH_ORIGIN, false);
await connect(bg, FRESH_OTHER_PORT, true);
const settings = await openSettings(bg);
await settings.remove(settings.allowed, "fresh.example");
await settings.remove(settings.allowed, FRESH_OTHER_PORT);
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
result: [],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
result: [signer.address],
});
});
test("a page can neither remove a site nor list the connected ones", async () => {
@@ -2343,7 +2411,7 @@ describe("removing a site in Settings disconnects it", () => {
const page = { url: FRESH_ORIGIN + "/index.html" };
const remove = bg.send(
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
{ type: "AUTISTMASK_REMOVE_SITE", origin: FRESH_ORIGIN },
page,
);
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);