diff --git a/README.md b/README.md index 90a7de1..cdd0b36 100644 --- a/README.md +++ b/README.md @@ -1157,7 +1157,7 @@ each caught only by a reviewer re-deriving thirty fields by hand. The `allowedSites` case is why the entry check is not optional. A stored `{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it passed the gate, rendered a completely healthy popup, and then threw inside -`saveState()`'s per-hostname merge, so every save from that moment on failed and +`saveState()`'s per-origin merge, so every save from that moment on failed and the user went on operating a wallet that was persisting nothing ([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is now also reported rather than swallowed: `onSaveFailure()` in @@ -1631,13 +1631,13 @@ view would leave a wallet one click from deletion. a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation — is refused with a flash message and the field snaps back to the stored threshold, so a number the user did not type is never stored. - - Allowed Sites: the hostnames remembered as allowed, under any address, - with remove buttons - - Connected Sites: the hostnames of the sites allowed without "Remember my + - Allowed Sites: the origins (scheme, host and port) remembered as allowed, + under any address, with remove buttons + - Connected Sites: the origins of the sites allowed without "Remember my choice" that are still connected, with remove buttons. Only the background holds these, in memory, and Settings asks it for them with `AUTISTMASK_GET_CONNECTED_SITES` - - Denied Sites: the hostnames remembered as denied, under any address, with + - Denied Sites: the origins remembered as denied, under any address, with remove buttons - About: project link, license, author, version, release date, and the commit, which links to the commit in the repository @@ -1651,10 +1651,11 @@ view would leave a wallet one click from deletion. - Tap wallet name → inline rename field (no screen change) - `[x]` on a tracked token → removes it in place (no screen change) - `[x]` on an allowed or connected site → disconnects that site, in place: - its hostname is dropped from Allowed Sites under every address, and + its origin is dropped from Allowed Sites under every address, and `AUTISTMASK_REMOVE_SITE` has the background end every connection approved - without "Remember" from an origin with that hostname, under any address, - and send `accountsChanged` with an empty list to the site's open tabs. + without "Remember" from that origin, under any address, and send + `accountsChanged` with an empty list to the open tabs of that origin. The + same host under another scheme or port is another site and is left alone. Only the extension's own pages may send either message - `[x]` on a denied site → forgets the refusal, in place; it connects nothing and tells the background nothing @@ -1838,14 +1839,18 @@ view would leave a wallet one click from deletion. - **When**: A website requests wallet access via `eth_requestAccounts` or `wallet_requestPermissions` and is on neither the allowed nor the denied list. - The background script prefers the toolbar popup (`action.openPopup()`) and - falls back to a separate popup window (`src/background/index.js`, + A site is its full origin, `scheme://host[:port]`, on both lists and for a + connection allowed without "Remember": a choice for `https://dapp.example` + says nothing about `http://dapp.example` or another port of that host. The + background script prefers the toolbar popup (`action.openPopup()`) and falls + back to a separate popup window (`src/background/index.js`, `requestApproval()`). - **Elements**: - "Connection Request" heading - Phishing warning banner (shown when the hostname is on the phishing blocklist) - - Site hostname (bold) + "wants to connect to your wallet" + - Site origin (bold, scheme and port included) + "wants to connect to your + wallet" - Address that will be shared (color dot + full address + etherscan link) - "Remember my choice for this site" checkbox - "Allow" / "Deny" buttons @@ -1875,7 +1880,8 @@ view would leave a wallet one click from deletion. - "Transaction Request" heading - Phishing warning banner (shown when the hostname is on the phishing blocklist) - - Site hostname (bold) + "wants to send a transaction" + - Site origin (bold, scheme and port included) + "wants to send a + transaction" - Decoded action (if calldata is recognized): action name, token details, amounts, steps, deadline (see Transaction Decoding) - From: color dot + full address + etherscan link @@ -1906,7 +1912,8 @@ view would leave a wallet one click from deletion. - "Signature Request" heading - Phishing warning banner (shown when the hostname is on the phishing blocklist) - - Site hostname (bold) + "wants you to sign a message" + - Site origin (bold, scheme and port included) + "wants you to sign a + message" - Danger warning box (shown for `eth_sign`, which signs a raw hash) - Type: "Personal message" or "Typed data (EIP-712)" - From: color dot + full address + etherscan link diff --git a/TODO.md b/TODO.md index b7734b1..530fb5d 100644 --- a/TODO.md +++ b/TODO.md @@ -45,6 +45,18 @@ but the review is broader than any of them. # Completed Steps +- 2026-10-04: Remembered site permissions are held by full origin + ([#402](https://git.eeqj.de/sneak/AutistMask/issues/402)). `allowedSites` and + `deniedSites` stored the hostname alone, so a grant to `https://dapp.example` + also authorised `http://dapp.example` and every port on that host, and the + prompts named only the hostname. Both lists now store and match the origin + (`scheme://host[:port]`), the key the connections approved without "Remember" + already used, in `src/background/index.js` and in Settings, whose site lists + and `AUTISTMASK_REMOVE_SITE` carry the origin too. The connection, transaction + and signature prompts show the origin. Entries saved by hostname before this + change are not migrated (pre-1.0): they match no site, and Settings lists them + until they are removed. + - 2026-10-04: A token whose scale is unknown reads the same on the Send screen as on the confirmation screen ([#377](https://git.eeqj.de/sneak/AutistMask/issues/377)). When two addresses' diff --git a/docs/README.md b/docs/README.md index 774e461..deac2a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -285,11 +285,13 @@ not appear and may be permanently lost. AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web pages. When a site requests access to your wallet: -1. A popup appears showing the site's hostname and the address that will be - shared. +1. A popup appears showing the site's origin (its scheme, host and port, for + example `https://app.example`) and the address that will be shared. 2. Click "Allow" to connect or "Deny" to reject. 3. Optionally check "Remember my choice for this site" to skip the prompt next - time. + time. The choice applies to that exact origin only: a choice remembered for + `https://app.example` does not cover `http://app.example` or another port of + the same host, which ask again. When a connected site requests a transaction, a separate approval popup appears showing the transaction details (from, to, value, data, network fee, network and diff --git a/src/background/index.js b/src/background/index.js index 5172cba..7dc99e6 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -57,9 +57,13 @@ const windowsNs = windowsApi(); const actionNs = actionApi(); // Connected sites (in-memory, non-persisted): { "origin:address": true } +// +// A site is its full origin (scheme://host[:port]), here and in the +// remembered allowedSites/deniedSites lists alike: a grant to +// https://dapp.example says nothing about http://dapp.example or another port. const connectedSites = {}; -// Pending approval requests: { id: { origin, hostname, resolve } } +// Pending approval requests: { id: { origin, resolve } } const pendingApprovals = {}; // One transaction approval at a time, wallet-wide. @@ -459,10 +463,10 @@ async function openApprovalWindow(id) { // Open an approval popup and return a promise that resolves with the user decision. // Prefers the browser-action popup (anchored to toolbar, no macOS Space switch). -function requestApproval(origin, hostname) { +function requestApproval(origin) { return new Promise((resolve) => { const id = crypto.randomUUID(); - pendingApprovals[id] = { id, origin, hostname, resolve }; + pendingApprovals[id] = { id, origin, resolve }; if (actionNs && typeof actionNs.openPopup === "function") { actionNs.setPopup({ @@ -495,13 +499,12 @@ function requestApproval(origin, hostname) { // screen never named. // `slot` is the transaction-approval slot its caller holds. Handing the // approval's id to it is what makes retiring the approval free the slot. -function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) { +function requestTxApproval(origin, approvedTx, approvedFrom, slot) { return new Promise((resolve) => { const id = crypto.randomUUID(); pendingApprovals[id] = { id, origin, - hostname, approvedTx, approvedFrom, resolve, @@ -517,13 +520,12 @@ function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) { // Uses windows.create() directly because sign approvals are triggered programmatically // (from a dApp RPC call), not from a user gesture, so action.openPopup() is // unreliable in this context. -function requestSignApproval(origin, hostname, signParams, approvedFrom) { +function requestSignApproval(origin, signParams, approvedFrom) { return new Promise((resolve) => { const id = crypto.randomUUID(); pendingApprovals[id] = { id, origin, - hostname, signParams, approvedFrom, resolve, @@ -601,11 +603,11 @@ runtime.onConnect.addListener((port) => { // in the worker — a balance refresh in flight, another site's approval — has // gone on running the whole time. Loading here used to replace the very // objects that work was holding. -async function rememberSiteChoice(field, address, hostname) { +async function rememberSiteChoice(field, address, origin) { await updateState((s) => { if (!s[field][address]) s[field][address] = []; - if (!s[field][address].includes(hostname)) { - s[field][address].push(hostname); + if (!s[field][address].includes(origin)) { + s[field][address].push(origin); } }); } @@ -618,12 +620,11 @@ async function handleConnectionRequest(origin) { return { error: { message: "No accounts available" } }; } - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; const denied = s.deniedSites[activeAddress] || []; // Check denied list - if (denied.includes(hostname)) { + if (denied.includes(origin)) { return { error: { code: 4001, @@ -634,25 +635,25 @@ async function handleConnectionRequest(origin) { // Check allowed list or in-memory connected if ( - allowed.includes(hostname) || + allowed.includes(origin) || connectedSites[origin + ":" + activeAddress] ) { return { result: [activeAddress] }; } // Open approval popup - const decision = await requestApproval(origin, hostname); + const decision = await requestApproval(origin); if (decision.approved) { if (decision.remember) { - await rememberSiteChoice("allowedSites", activeAddress, hostname); + await rememberSiteChoice("allowedSites", activeAddress, origin); } else { connectedSites[origin + ":" + activeAddress] = true; } return { result: [activeAddress] }; } else { if (decision.remember) { - await rememberSiteChoice("deniedSites", activeAddress, hostname); + await rememberSiteChoice("deniedSites", activeAddress, origin); } return { error: { @@ -698,10 +699,9 @@ async function handleRpc(method, params, origin) { const s = await getState(); const activeAddress = activeAddressOf(s); if (!activeAddress) return { result: [] }; - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; if ( - allowed.includes(hostname) || + allowed.includes(origin) || connectedSites[origin + ":" + activeAddress] ) { return { result: [activeAddress] }; @@ -731,10 +731,9 @@ async function handleRpc(method, params, origin) { // [TESTNET] banner under a user who believed they were on Sepolia. const s = await getState(); const activeAddress = activeAddressOf(s); - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; if ( - !allowed.includes(hostname) && + !allowed.includes(origin) && !connectedSites[origin + ":" + activeAddress] ) { return { error: { code: 4100, message: "Unauthorized" } }; @@ -806,10 +805,9 @@ async function handleRpc(method, params, origin) { if (method === "wallet_getPermissions") { const s = await getState(); const activeAddress = activeAddressOf(s); - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; const isConnected = - allowed.includes(hostname) || + allowed.includes(origin) || connectedSites[origin + ":" + activeAddress]; if (!isConnected || !activeAddress) { return { result: [] }; @@ -835,10 +833,9 @@ async function handleRpc(method, params, origin) { if (!activeAddress) return { error: { message: "No accounts available" } }; - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; if ( - !allowed.includes(hostname) && + !allowed.includes(origin) && !connectedSites[origin + ":" + activeAddress] ) { return { error: { code: 4100, message: "Unauthorized" } }; @@ -870,7 +867,6 @@ async function handleRpc(method, params, origin) { const decision = await requestSignApproval( origin, - hostname, signParams, activeAddress, ); @@ -884,10 +880,9 @@ async function handleRpc(method, params, origin) { if (!activeAddress) return { error: { message: "No accounts available" } }; - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; if ( - !allowed.includes(hostname) && + !allowed.includes(origin) && !connectedSites[origin + ":" + activeAddress] ) { return { error: { code: 4100, message: "Unauthorized" } }; @@ -905,7 +900,6 @@ async function handleRpc(method, params, origin) { } const decision = await requestSignApproval( origin, - hostname, signParams, activeAddress, ); @@ -946,10 +940,9 @@ async function handleSendTransaction(params, origin) { const activeAddress = activeAddressOf(s); if (!activeAddress) return { error: { message: "No accounts available" } }; - const hostname = extractHostname(origin); const allowed = s.allowedSites[activeAddress] || []; if ( - !allowed.includes(hostname) && + !allowed.includes(origin) && !connectedSites[origin + ":" + activeAddress] ) { return { error: { code: 4100, message: "Unauthorized" } }; @@ -1023,7 +1016,6 @@ async function handleSendTransaction(params, origin) { const decision = await requestTxApproval( origin, - hostname, approvedTx, activeAddress, slot, @@ -1097,10 +1089,9 @@ async function broadcastAccountsChanged() { } for (const tab of tabs) { const origin = tab.url ? new URL(tab.url).origin : ""; - const hostname = extractHostname(origin); const hasPermission = activeAddress && - (allowed.includes(hostname) || + (allowed.includes(origin) || connectedSites[origin + ":" + activeAddress]); // Same as chainChanged above: a tab without our content script // rejects, and that is expected rather than a fault. @@ -1113,7 +1104,7 @@ async function broadcastAccountsChanged() { } // Tell every open tab of a site Settings removed that it has no account. -async function broadcastSiteRemoved(hostname) { +async function broadcastSiteRemoved(origin) { let tabs; try { tabs = await tabsQuery({}); @@ -1121,7 +1112,7 @@ async function broadcastSiteRemoved(hostname) { return; } for (const tab of tabs) { - if (!tab.url || extractHostname(tab.url) !== hostname) continue; + if (!tab.url || new URL(tab.url).origin !== origin) continue; tabsSendMessage(tab.id, { type: "AUTISTMASK_EVENT", eventName: "accountsChanged", @@ -1337,10 +1328,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "AUTISTMASK_GET_APPROVAL") { const approval = pendingApprovals[msg.id]; if (approval) { - const resp = { - hostname: approval.hostname, - origin: approval.origin, - }; + const resp = { origin: approval.origin }; if (approval.type === "tx") { resp.type = "tx"; // The populated transaction, and the address it was raised @@ -1355,7 +1343,9 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { resp.approvedFrom = approval.approvedFrom; } // Flag if the requesting domain is on the phishing blocklist. - resp.isPhishingDomain = isPhishingDomain(approval.hostname); + resp.isPhishingDomain = isPhishingDomain( + extractHostname(approval.origin), + ); sendResponse(resp); } else { sendResponse(null); @@ -1689,23 +1679,22 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => { if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") { sendResponse( Object.keys(connectedSites).map((key) => - extractHostname(key.slice(0, key.lastIndexOf(":"))), + key.slice(0, key.lastIndexOf(":")), ), ); return false; } - // Settings removed this site and has already dropped its remembered - // entries. Its connections approved without "Remember" end here, under - // every address, and its open tabs are told it has no account. + // Settings removed this site (msg.origin) and has already dropped its + // remembered entries. Its connections approved without "Remember" end + // here, under every address, and its open tabs are told it has no account. if (msg.type === "AUTISTMASK_REMOVE_SITE") { for (const key of Object.keys(connectedSites)) { - const origin = key.slice(0, key.lastIndexOf(":")); - if (extractHostname(origin) === msg.hostname) { + if (key.slice(0, key.lastIndexOf(":")) === msg.origin) { delete connectedSites[key]; } } - broadcastSiteRemoved(msg.hostname); + broadcastSiteRemoved(msg.origin); return false; } }); diff --git a/src/popup/index.html b/src/popup/index.html index 0f14a74..2fd624d 100644 --- a/src/popup/index.html +++ b/src/popup/index.html @@ -1561,7 +1561,7 @@ with extreme caution.
- + wants to send a transaction.
@@ -1662,7 +1662,7 @@ funds. Proceed with extreme caution.- + wants you to sign a message.
@@ -1740,7 +1740,7 @@- + wants to connect to your wallet.
None
'; return; } let html = ""; - unique.forEach((hostname) => { + unique.forEach((origin) => { html += `