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
+36 -47
View File
@@ -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",
@@ -1348,10 +1339,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
@@ -1366,7 +1354,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);
@@ -1700,23 +1690,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;
}
});
+3 -3
View File
@@ -1561,7 +1561,7 @@
with extreme caution.
</div>
<p class="mb-2">
<span id="approve-tx-hostname" class="font-bold"></span>
<span id="approve-tx-origin" class="font-bold"></span>
wants to send a transaction.
</p>
@@ -1662,7 +1662,7 @@
funds. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-sign-hostname" class="font-bold"></span>
<span id="approve-sign-origin" class="font-bold"></span>
wants you to sign a message.
</p>
@@ -1740,7 +1740,7 @@
</div>
<div class="mb-3">
<p class="mb-2">
<span id="approve-hostname" class="font-bold"></span>
<span id="approve-origin" class="font-bold"></span>
wants to connect to your wallet.
</p>
<div class="text-xs text-muted mb-1">
+3 -3
View File
@@ -306,7 +306,7 @@ function showTxApproval(details) {
};
}
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-origin").textContent = details.origin;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known
@@ -645,7 +645,7 @@ function showSignApproval(details) {
pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-origin").textContent = details.origin;
$("approve-sign-from").innerHTML = approvalAddressHtml(
details.approvedFrom,
);
@@ -732,7 +732,7 @@ async function show(id) {
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-origin").textContent = details.origin;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
+17 -17
View File
@@ -34,35 +34,35 @@ const { notify, sendMessage } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
// One row per hostname, however many addresses or origins it appears under,
// each with an [x] that hands it to onRemove.
function renderSiteList(containerId, hostnames, onRemove) {
// One row per site origin, however many addresses it appears under, each with
// an [x] that hands it to onRemove.
function renderSiteList(containerId, origins, onRemove) {
const container = $(containerId);
const unique = [...new Set(hostnames)];
const unique = [...new Set(origins)];
if (unique.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
unique.forEach((hostname) => {
unique.forEach((origin) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
// A hostname the URL parser produced cannot carry a delimiter, so
// An origin the URL parser produced cannot carry a delimiter, so
// this is escaped for the rule rather than for a known hole — the
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `<span>${escapeHtml(origin)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-origin="${escapeHtml(origin)}">[x]</button>`;
html += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
});
}
// Drop a hostname from a remembered site list under every address.
function forgetHostname(siteMap, hostname) {
// Drop a site origin from a remembered site list under every address.
function forgetOrigin(siteMap, origin) {
for (const addr of Object.keys(siteMap)) {
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
if (siteMap[addr].length === 0) {
delete siteMap[addr];
}
@@ -72,16 +72,16 @@ function forgetHostname(siteMap, hostname) {
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
// no longer allowed under any address, and the background ends its
// connections approved without "Remember" and tells its open tabs.
async function removeAllowedSite(hostname) {
forgetHostname(state.allowedSites, hostname);
async function removeAllowedSite(origin) {
forgetOrigin(state.allowedSites, origin);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
await renderSiteLists();
}
// Removing a denied site only forgets the refusal; it connects nothing.
async function removeDeniedSite(hostname) {
forgetHostname(state.deniedSites, hostname);
async function removeDeniedSite(origin) {
forgetOrigin(state.deniedSites, origin);
await saveState();
await renderSiteLists();
}
+11 -10
View File
@@ -102,11 +102,11 @@ function tokenRefs(value) {
// A list of strings, for the fields whose entries are dereferenced as text:
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` in the background).
// src/shared/transactions.js) and each address's origin list in the site maps
// below (`o !== origin` filters, `list.includes(origin)` in the background).
//
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// with a malformed entry DROPPED rather than repaired. A number in an origin
// list names no site and a number in fraudContracts names no contract, so there
// is nothing to repair either to, and the empty list is a legitimate value that
// survives. The result is a fresh array of primitives, so it shares no
@@ -116,21 +116,22 @@ function textList(value) {
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
// full scheme://host[:port] of a site.
//
// The container check these had (truthy and not an array) is not the floor:
// `{"0xabc…": "notalist"}` IS a non-array object, and the dereference is one
// level below it. saveState() merges these maps per key and then per hostname
// level below it. saveState() merges these maps per key and then per origin
// WITHIN each key, so a stored value that is not a list reaches `base.map()` in
// mergeListByIdentity() (src/shared/state.js) and throws — after the popup has
// rendered, which is why every save from then on failed while the UI looked
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
// revoke button (`list.filter()`), and the background's
// `allowed.includes(hostname)` gate, dereference it the same way; on that last
// `allowed.includes(origin)` gate, dereference it the same way; on that last
// one a stored string would also answer a SUBSTRING match, so a corrupt map
// could widen a site permission rather than merely throw.
//
// An address key whose value is not a list of hostnames is dropped entirely: it
// An address key whose value is not a list of origins is dropped entirely: it
// grants and denies nothing, and dropping it fails closed. A stored own
// "__proto__" key — which JSON can carry — is dropped for the same reason: it
// can never be a wallet address, so it grants nothing either, and keeping it
@@ -143,9 +144,9 @@ function siteMap(value) {
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames);
const origins = textList(value[address]);
if (origins.length === 0) continue;
defineOwn(out, address, origins);
}
return out;
}
+10 -10
View File
@@ -304,7 +304,7 @@ function mergeAddress(base, ours, theirs) {
}
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
// hostname list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// same way mergeListByIdentity() merges an array — by key, not by whole-
// object diff — so a key one page added or removed applies independently of
// a key another page edited. Unlike an array's identity function, an object
@@ -353,25 +353,25 @@ function mergeMapByKey(base, ours, theirs, mergeLeaf) {
return result;
}
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// allowedSites/deniedSites: { [address]: [origin, ...] }. The origin
// list is itself membership, not a leaf — the background appends a newly
// approved/denied hostname to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters a hostname out of it in place, from a
// approved/denied origin to it, and the Settings "revoke" button
// (src/popup/views/settings.js) filters an origin out of it in place, from a
// different page. Merge it the same way wallets are merged: identity is the
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
// origin itself, so a merged pair is always equal and mergeItem is a no-op
// pick.
function mergeHostnameList(base, ours, theirs) {
function mergeOriginList(base, ours, theirs) {
return mergeListByIdentity(
base,
ours,
theirs,
(hostname) => hostname,
(origin) => origin,
(b, o, t) => t,
);
}
function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
return mergeMapByKey(base, ours, theirs, mergeOriginList);
}
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
@@ -422,8 +422,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
// address) apply independently instead of colliding as the same field.
//
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
// by address key and then by hostname within each address's list), for the
// identical reason: the background appends a newly approved/denied hostname
// by address key and then by origin within each address's list), for the
// identical reason: the background appends a newly approved/denied origin
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
// filters one out in place, from a different page. A whole-field diff here
// doesn't just lose data, it is a security defect — a stale page's save can