Merge branch 'main' into fix/low-severity-security
All checks were successful
check / check (push) Successful in 22s

This commit is contained in:
2026-02-27 20:58:09 +01:00
11 changed files with 156 additions and 92 deletions

View File

@@ -15,9 +15,10 @@ Hence, a minimally viable ERC20 browser wallet/signer that works cross-platform.
Everything you need, nothing you don't. We import as few libraries as possible, Everything you need, nothing you don't. We import as few libraries as possible,
don't implement any crypto, and don't send user-specific data anywhere but a don't implement any crypto, and don't send user-specific data anywhere but a
(user-configurable) Ethereum RPC endpoint (which defaults to a public node). The (user-configurable) Ethereum RPC endpoint (which defaults to a public node). The
extension contacts precisely two external services: the configured RPC node for extension contacts exactly three external services: the configured RPC node for
blockchain interactions, and a public CoinDesk API (no API key) to get realtime blockchain interactions, a public CoinDesk API (no API key) for realtime price
price information. information, and a Blockscout block-explorer API for transaction history and
token balances. All three endpoints are user-configurable.
In the extension is a hardcoded list of the top ERC20 contract addresses. You In the extension is a hardcoded list of the top ERC20 contract addresses. You
can add any ERC20 contract by contract address if you wish, but the hardcoded can add any ERC20 contract by contract address if you wish, but the hardcoded
@@ -534,7 +535,7 @@ transitions.
### External Services ### External Services
AutistMask is not a fully self-contained offline tool. It necessarily AutistMask is not a fully self-contained offline tool. It necessarily
communicates with two external services to function as a wallet: communicates with three external services to function as a wallet:
- **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query - **Ethereum JSON-RPC endpoint**: The extension needs an Ethereum node to query
balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`), balances (`eth_getBalance`), read ERC-20 token contracts (`eth_call`),
@@ -543,11 +544,24 @@ communicates with two external services to function as a wallet:
receipts. The default endpoint is a public RPC (configurable by the user to receipts. The default endpoint is a public RPC (configurable by the user to
any endpoint they prefer, including a local node). By default the extension any endpoint they prefer, including a local node). By default the extension
talks to `https://ethereum-rpc.publicnode.com`. talks to `https://ethereum-rpc.publicnode.com`.
- **Data sent**: Ethereum addresses, transaction data, contract call
parameters. The RPC endpoint can see all on-chain queries and submitted
transactions.
- **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for - **CoinDesk CADLI price API**: Used to fetch ETH/USD and token/USD prices for
displaying fiat values. The price is cached for 5 minutes to avoid excessive displaying fiat values. The price is cached for 5 minutes to avoid excessive
requests. No API key required. No user data is sent — only a list of token requests. No API key required. No user data is sent — only a list of token
symbols. Note that CoinDesk will receive your client IP. symbols. Note that CoinDesk will receive your client IP.
- **Data sent**: Token symbol strings only (e.g. "ETH", "USDC"). No
addresses or user-specific data.
- **Blockscout block-explorer API**: Used to fetch transaction history (normal
transactions and ERC-20 token transfers), ERC-20 token balances, and token
holder counts (for spam filtering). The default endpoint is
`https://eth.blockscout.com/api/v2` (configurable by the user in Settings).
- **Data sent**: Ethereum addresses. Blockscout receives the user's
addresses to query their transaction history and token balances. No
private keys, passwords, or signing operations are sent.
What the extension does NOT do: What the extension does NOT do:
@@ -557,9 +571,10 @@ What the extension does NOT do:
- No Infura/Alchemy dependency (any JSON-RPC endpoint works) - No Infura/Alchemy dependency (any JSON-RPC endpoint works)
- No backend servers operated by the developer - No backend servers operated by the developer
The user's RPC endpoint and the CoinDesk price API are the only external These three services (RPC endpoint, CoinDesk price API, and Blockscout API) are
services. Users who want maximum privacy can point the RPC at their own node the only external services. All three endpoints are user-configurable. Users who
(price fetching can be disabled in a future version). want maximum privacy can point the RPC and Blockscout URLs at their own
self-hosted instances (price fetching can be disabled in a future version).
### Dependencies ### Dependencies

View File

@@ -1,3 +1,8 @@
> **⚠️ THIS FILE MUST NEVER BE MODIFIED BY AGENTS.** RULES.md is maintained
> exclusively by the project owner. AI agents, bots, and automated tools must
> treat this file as read-only. If an audit finds a divergence between the code
> and this file, the code must be changed to match — never the other way around.
# AutistMask Rules Checklist # AutistMask Rules Checklist
This file is derived from README.md and REPO_POLICIES.md for use as an audit This file is derived from README.md and REPO_POLICIES.md for use as an audit
@@ -17,8 +22,8 @@ contradicts either, the originals govern.
## External Communication ## External Communication
- [ ] Extension contacts exactly two external services: configured RPC endpoint - [ ] Extension contacts exactly three external services: configured RPC
and CoinDesk price API endpoint, CoinDesk price API, and Blockscout block-explorer API
- [ ] No analytics, telemetry, or tracking - [ ] No analytics, telemetry, or tracking
- [ ] No user-specific data sent except to the configured RPC endpoint - [ ] No user-specific data sent except to the configured RPC endpoint
- [ ] No Infura/Alchemy hard dependency - [ ] No Infura/Alchemy hard dependency

View File

@@ -30,7 +30,6 @@ const connectedSites = {};
// Pending approval requests: { id: { origin, hostname, resolve } } // Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {}; const pendingApprovals = {};
let nextApprovalId = 1;
async function getState() { async function getState() {
const result = await storageApi.get("autistmask"); const result = await storageApi.get("autistmask");
@@ -127,7 +126,7 @@ function openApprovalWindow(id) {
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch). // Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
function requestApproval(origin, hostname) { function requestApproval(origin, hostname) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = nextApprovalId++; const id = crypto.randomUUID();
pendingApprovals[id] = { origin, hostname, resolve }; pendingApprovals[id] = { origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") { if (actionApi && typeof actionApi.openPopup === "function") {
@@ -152,7 +151,7 @@ function requestApproval(origin, hostname) {
// Uses the toolbar popup only — no fallback window. // Uses the toolbar popup only — no fallback window.
function requestTxApproval(origin, hostname, txParams) { function requestTxApproval(origin, hostname, txParams) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = nextApprovalId++; const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
@@ -184,7 +183,7 @@ function requestTxApproval(origin, hostname, txParams) {
// popup URL is still set, so the user can click the toolbar icon to respond. // popup URL is still set, so the user can click the toolbar icon to respond.
function requestSignApproval(origin, hostname, signParams) { function requestSignApproval(origin, hostname, signParams) {
return new Promise((resolve) => { return new Promise((resolve) => {
const id = nextApprovalId++; const id = crypto.randomUUID();
pendingApprovals[id] = { pendingApprovals[id] = {
origin, origin,
hostname, hostname,
@@ -216,7 +215,7 @@ function requestSignApproval(origin, hostname, signParams) {
// popups naturally close on focus loss and the user can reopen them. // popups naturally close on focus loss and the user can reopen them.
runtime.onConnect.addListener((port) => { runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) { if (port.name.startsWith("approval:")) {
const id = parseInt(port.name.split(":")[1], 10); const id = port.name.split(":")[1];
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
const approval = pendingApprovals[id]; const approval = pendingApprovals[id];
if (approval) { if (approval) {
@@ -442,6 +441,13 @@ async function handleRpc(method, params, origin) {
? { method, message: params[0], from: params[1] } ? { method, message: params[0], from: params[1] }
: { method, message: params[1], from: params[0] }; : { method, message: params[1], from: params[0] };
if (method === "eth_sign") {
signParams.dangerWarning =
"\u26a0\ufe0f DANGER: This site is requesting to sign a raw hash. " +
"This can be used to sign transactions that drain your funds. " +
"Only proceed if you fully understand what you are signing.";
}
const decision = await requestSignApproval( const decision = await requestSignApproval(
origin, origin,
hostname, hostname,
@@ -611,12 +617,39 @@ if (windowsApi && windowsApi.onRemoved) {
// Listen for messages from content scripts and popup // Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => { runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_RPC") { if (msg.type === "AUTISTMASK_RPC") {
handleRpc(msg.method, msg.params, msg.origin).then((response) => { // Derive origin from trusted sender info to prevent origin spoofing.
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
if (sender.origin) {
trustedOrigin = sender.origin;
} else if (sender.tab && sender.tab.url) {
try {
trustedOrigin = new URL(sender.tab.url).origin;
} catch {
// keep fallback
}
}
handleRpc(msg.method, msg.params, trustedOrigin).then((response) => {
sendResponse(response); sendResponse(response);
}); });
return true; return true;
} }
// Validate that popup-only messages originate from the extension itself.
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 (msg.type === "AUTISTMASK_GET_APPROVAL") { if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id]; const approval = pendingApprovals[msg.id];
if (approval) { if (approval) {
@@ -681,7 +714,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break; if (wallet) break;
} }
if (!wallet) throw new Error("Wallet not found"); if (!wallet) throw new Error("Wallet not found");
const decrypted = await decryptWithPassword( // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
wallet.encryptedSecret, wallet.encryptedSecret,
msg.password, msg.password,
); );
@@ -690,6 +724,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex, addrIndex,
decrypted, decrypted,
); );
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const provider = getProvider(state.rpcUrl); const provider = getProvider(state.rpcUrl);
const connected = signer.connect(provider); const connected = signer.connect(provider);
const tx = await connected.sendTransaction(approval.txParams); const tx = await connected.sendTransaction(approval.txParams);
@@ -735,7 +773,8 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (wallet) break; if (wallet) break;
} }
if (!wallet) throw new Error("Wallet not found"); if (!wallet) throw new Error("Wallet not found");
const decrypted = await decryptWithPassword( // TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
let decrypted = await decryptWithPassword(
wallet.encryptedSecret, wallet.encryptedSecret,
msg.password, msg.password,
); );
@@ -744,6 +783,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
addrIndex, addrIndex,
decrypted, decrypted,
); );
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decrypted = null;
const sp = approval.signParams; const sp = approval.signParams;
let signature; let signature;

View File

@@ -1015,6 +1015,17 @@
wants you to sign a message. wants you to sign a message.
</p> </p>
<div
id="approve-sign-danger-warning"
class="hidden mb-3 p-2 text-xs font-bold"
style="
background: #fee2e2;
color: #991b1b;
border: 2px solid #dc2626;
border-radius: 6px;
"
></div>
<div class="mb-3"> <div class="mb-3">
<div class="text-xs text-muted mb-1">Type</div> <div class="text-xs text-muted mb-1">Type</div>
<div id="approve-sign-type" class="text-xs font-bold"></div> <div id="approve-sign-type" class="text-xs font-bold"></div>

View File

@@ -49,8 +49,8 @@ function init(ctx) {
showFlash("Please choose a password."); showFlash("Please choose a password.");
return; return;
} }
if (pw.length < 8) { if (pw.length < 12) {
showFlash("Password must be at least 8 characters."); showFlash("Password must be at least 12 characters.");
return; return;
} }
if (pw !== pw2) { if (pw !== pw2) {

View File

@@ -294,6 +294,18 @@ function showSignApproval(details) {
} }
} }
// Display danger warning for eth_sign (raw hash signing)
const warningEl = $("approve-sign-danger-warning");
if (warningEl) {
if (sp.dangerWarning) {
warningEl.textContent = sp.dangerWarning;
warningEl.classList.remove("hidden");
} else {
warningEl.textContent = "";
warningEl.classList.add("hidden");
}
}
$("approve-sign-password").value = ""; $("approve-sign-password").value = "";
$("approve-sign-error").classList.add("hidden"); $("approve-sign-error").classList.add("hidden");
$("btn-approve-sign").disabled = false; $("btn-approve-sign").disabled = false;
@@ -373,6 +385,7 @@ function init(ctx) {
type: "AUTISTMASK_TX_RESPONSE", type: "AUTISTMASK_TX_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password, password: password,
}, },
(response) => { (response) => {
@@ -412,6 +425,7 @@ function init(ctx) {
type: "AUTISTMASK_SIGN_RESPONSE", type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId, id: approvalId,
approved: true, approved: true,
// TODO(security): Move decryption to popup to avoid sending password via runtime.sendMessage
password: password, password: password,
}, },
(response) => { (response) => {

View File

@@ -334,8 +334,13 @@ function init(ctx) {
tx = await contract.transfer(pendingTx.to, amount); tx = await contract.transfer(pendingTx.to, amount);
} }
// Best-effort: clear decrypted secret after use.
// Note: JS strings are immutable; this nulls the reference but
// the original string may persist in memory until GC.
decryptedSecret = null;
txStatus.showWait(pendingTx, tx.hash); txStatus.showWait(pendingTx, tx.hash);
} catch (e) { } catch (e) {
decryptedSecret = null;
const hash = tx ? tx.hash : null; const hash = tx ? tx.hash : null;
txStatus.showError(pendingTx, hash, e.shortMessage || e.message); txStatus.showError(pendingTx, hash, e.shortMessage || e.message);
} }

View File

@@ -85,7 +85,7 @@ function showFlash(msg, duration = 2000) {
function balanceLine(symbol, amount, price, tokenId) { function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4); const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) : ""; const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;";
const tokenAttr = tokenId ? ` data-token="${tokenId}"` : ""; const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
const clickClass = tokenId const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row" ? " cursor-pointer hover:bg-hover balance-row"
@@ -222,6 +222,41 @@ function formatAddressHtml(address, ensName, maxLen, title) {
return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`; return `<div class="flex items-center">${dot}<span class="break-all">${escapeHtml(displayAddr)}</span></div>`;
} }
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
module.exports = { module.exports = {
$, $,
showError, showError,
@@ -236,4 +271,6 @@ module.exports = {
addressTitle, addressTitle,
formatAddressHtml, formatAddressHtml,
truncateMiddle, truncateMiddle,
isoDate,
timeAgo,
}; };

View File

@@ -3,6 +3,8 @@ const {
showView, showView,
showFlash, showFlash,
balanceLinesForAddress, balanceLinesForAddress,
isoDate,
timeAgo,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
truncateMiddle, truncateMiddle,
@@ -87,41 +89,6 @@ function renderActiveAddress() {
} }
} }
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
let homeTxs = []; let homeTxs = [];
function renderHomeTxList(ctx) { function renderHomeTxList(ctx) {

View File

@@ -30,8 +30,8 @@ function init(ctx) {
showFlash("Please choose a password."); showFlash("Please choose a password.");
return; return;
} }
if (pw.length < 8) { if (pw.length < 12) {
showFlash("Password must be at least 8 characters."); showFlash("Password must be at least 12 characters.");
return; return;
} }
if (pw !== pw2) { if (pw !== pw2) {

View File

@@ -8,6 +8,8 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
isoDate,
timeAgo,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state } = require("../../shared/state");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
@@ -21,41 +23,6 @@ const EXT_ICON =
let ctx; let ctx;
function isoDate(timestamp) {
const d = new Date(timestamp * 1000);
const pad = (n) => String(n).padStart(2, "0");
return (
d.getFullYear() +
"-" +
pad(d.getMonth() + 1) +
"-" +
pad(d.getDate()) +
" " +
pad(d.getHours()) +
":" +
pad(d.getMinutes()) +
":" +
pad(d.getSeconds())
);
}
function timeAgo(timestamp) {
const seconds = Math.floor(Date.now() / 1000 - timestamp);
if (seconds < 60) return seconds + " seconds ago";
const minutes = Math.floor(seconds / 60);
if (minutes < 60)
return minutes + " minute" + (minutes !== 1 ? "s" : "") + " ago";
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + " hour" + (hours !== 1 ? "s" : "") + " ago";
const days = Math.floor(hours / 24);
if (days < 30) return days + " day" + (days !== 1 ? "s" : "") + " ago";
const months = Math.floor(days / 30);
if (months < 12)
return months + " month" + (months !== 1 ? "s" : "") + " ago";
const years = Math.floor(days / 365);
return years + " year" + (years !== 1 ? "s" : "") + " ago";
}
function copyableHtml(text, extraClass) { function copyableHtml(text, extraClass) {
const cls = const cls =
"underline decoration-dashed cursor-pointer" + "underline decoration-dashed cursor-pointer" +