Compare commits

..
1 Commits
Author SHA1 Message Date
sneak bd3f8363e0 fix: decode Uniswap V2 exact-out swaps, input amount shown as a maximum (closes #283)
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
decode() had no arm for Universal Router command 0x09, so the approval
screen showed no token or amount for it. It now takes the path's first
token and amountInMax as the input side, the last token and amountOut as
the output side. With such a step, the Amount figure reads "Up to
<amount>" whichever step set it, on the approval, wait, success and error
screens, unless it reads "Unlimited", as an unbounded PERMIT2_PERMIT does,
or "All available (V4 open delta)". In every swap, UNWRAP_WETH makes
Token Out ETH only when the output side is WETH, on mainnet or Sepolia, or
no step set it; otherwise the output keeps its own token and figure.
decodeV2SwapExactOut() loses its eslint-disable comment.

Model: opus-5-5
2026-10-04 09:47:25 +00:00
54 changed files with 388 additions and 2304 deletions
+6 -5
View File
@@ -8,11 +8,12 @@ WORKDIR /app
# image sets it.
ENV AUTISTMASK_LINT_NATIVE=1
# script/test's default 30s bound is the host figure. In here the same suite
# starts on a cold jest cache and shares the runner with the rest of the build,
# so 30s is too tight — it killed a healthy suite at 30.6s on a cold CI cache.
# 180s still catches a hang in three minutes and cannot be tripped by a suite
# that is merely running on contended hardware.
# script/test's default 30s bound is the host figure, against a suite that
# runs in about 8s there. In here the same suite starts on a cold jest cache
# and shares the runner with the rest of the build, so 30s is marginal rather
# than a bound — it killed a healthy suite at 30.6s on a cold CI cache. 180s
# still catches a hang in three minutes and cannot be tripped by a suite that
# is merely running on contended hardware.
ENV AUTISTMASK_TEST_TIMEOUT=180
# script/bootstrap installs all prerequisites (make via apt here; node
+32 -81
View File
@@ -949,13 +949,6 @@ and compare against. Reading the stored field directly instead answers `null`
for a bundled or tracked token the explorer merely omitted, which is not a
refusal the wallet has any reason to make.
The Send screen also consults every address's explorer reports, so a contract
two addresses report different `decimals` for has no scale there, and the stored
balance, formatted at one of those scales, is withdrawn with it. The Send
screen's `Current balance` and the confirmation screen's balance line then both
read `unknown (SYMBOL)`. The balance list formats each explorer row as it is
fetched, without that cross-address check, and shows the row's figure.
**Decoded amount lines on the transaction approval screen:** the `Amount` line
of a decoded ERC-20 call, and the `Amount` and `Min. received` lines of a
decoded swap (see TxApproval below), do not always read as a number. They can
@@ -1157,7 +1150,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-origin merge, so every save from that moment on failed and
`saveState()`'s per-hostname 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
@@ -1279,10 +1272,7 @@ view would leave a wallet one click from deletion.
of every wallet, deduplicated by hash and filtered. Each row is three
lines: age and direction, then the counterparty's colour dot (with our own
name for it, where it is one of our addresses) and the amount, then the
counterparty's full address on a row of its own. A contract creation has
no counterparty: its second line is the amount alone and its third line
says "This transaction creates a new contract. It has no recipient." The
transaction lists on AddressDetail and AddressToken draw the same rows
counterparty's full address on a row of its own
- "Add additional wallet..." link at bottom
- **Transitions**:
- Tap address row → sets the active address and broadcasts
@@ -1416,9 +1406,7 @@ view would leave a wallet one click from deletion.
- What to send: token dropdown (or static display with contract address when
locked from AddressToken)
- To: address or ENS name input, with an inline validation message
- Amount input with current balance display, which reads
`Current balance: unknown (SYMBOL)` for a token whose scale is unknown, as
ConfirmTx's balance line does (see Unknown token scale)
- Amount input with current balance display
- "Review" button, disabled until the recipient validates
- **Transitions**:
- "Review" (valid inputs, ENS resolved) → **ConfirmTx**
@@ -1436,8 +1424,7 @@ view would leave a wallet one click from deletion.
- From: blockie + color dot + full address + etherscan link + wallet title
- To: blockie + color dot + full address + etherscan link + ENS name
- Amount: value + symbol (USD in parentheses)
- Your balance: value + symbol (USD in parentheses), or `unknown (SYMBOL)`
for a token whose scale is unknown
- Your balance: value + symbol (USD in parentheses)
- Network fee: "Estimating..." then two lines, or "Unable to estimate",
fetched async. The first line is what the transfer is expected to cost,
`gasLimit * gasPrice` (USD in parentheses); the second is the
@@ -1453,11 +1440,7 @@ view would leave a wallet one click from deletion.
amount plus the fee exceeds the balance (ETH transfers), not enough ETH to
pay the fee for the transfer (ERC-20 transfers), and the fee could not be
estimated. The first two are mutually exclusive per transfer type, so only
the applicable one holds space. The last names its cause: for a token
whose scale is unknown the fee can never be estimated, and it says the
wallet does not know how many decimal places the token uses and that the
transaction cannot be sent; for any other failure it asks the user to go
back and try again
the applicable one holds space
- Password: an inline field on this screen, not a modal, with its own error
line
- "Sign & Send" button (disabled if errors, and while the network fee
@@ -1482,9 +1465,7 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Transaction Broadcast" heading (no back button — tx is irreversible)
- Amount + symbol
- To: color dot + full address + etherscan link; for a contract creation,
which has no recipient, "This transaction creates a new contract. It has
no recipient." instead
- To: color dot + full address + etherscan link
- Transaction hash: full hash (tap to copy) + etherscan link
- Count-up timer: "Waiting for confirmation... Ns"
- **Behavior**: Polls `getTransactionReceipt` every 10 seconds. The wait is
@@ -1513,8 +1494,7 @@ view would leave a wallet one click from deletion.
- Decoded action well (shown when the transaction carried recognized
calldata; the top-level Amount and To are hidden in that case)
- Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- To: color dot + full address + etherscan link
- Block number
- Transaction hash: full hash (tap to copy) + etherscan link
- "Done" button
@@ -1529,8 +1509,7 @@ view would leave a wallet one click from deletion.
- **Elements**:
- "Transaction Failed" heading
- Amount + symbol
- To: color dot + full address + etherscan link, or for a contract creation
the same sentence as on WaitTx
- To: color dot + full address + etherscan link
- Error message (dashed border box)
- Transaction hash section (hidden if broadcast failed before getting hash):
full hash (tap to copy) + etherscan link
@@ -1568,7 +1547,7 @@ view would leave a wallet one click from deletion.
- From: blockie + color dot + full address (tap to copy) + etherscan link;
ENS name if available
- To: blockie + color dot + full address (tap to copy) + etherscan link; ENS
name if available. For a contract creation, the same sentence as on WaitTx
name if available
- Time: ISO datetime + relative age in parentheses
- Block: block number (tap to copy) + etherscan block link
- Amount: value + symbol (bold)
@@ -1631,13 +1610,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 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
- Allowed Sites: the hostnames remembered as allowed, under any address,
with remove buttons
- Connected Sites: the hostnames 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 origins remembered as denied, under any address, with
- Denied Sites: the hostnames 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,11 +1630,10 @@ 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 origin is dropped from Allowed Sites under every address, and
its hostname is dropped from Allowed Sites under every address, and
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
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.
without "Remember" from an origin with that hostname, under any address,
and send `accountsChanged` with an empty list to the site's open tabs.
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
@@ -1839,22 +1817,14 @@ 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.
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()`). Only one exists per site at a time: a further connection
request from a site whose prompt is still unanswered is refused with EIP-1193
code `-32002` and opens no new prompt. If that prompt was in a toolbar popup
that closed before it connected, and the toolbar popup has since been set to
open something else, the refused request shows that prompt again.
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 origin (bold, scheme and port included) + "wants to connect to your
wallet"
- Site hostname (bold) + "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
@@ -1873,27 +1843,23 @@ view would leave a wallet one click from deletion.
programmatically rather than by a user gesture. The background populates the
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
opening the window, so the screen shows a complete transaction and the signed
artifact can be compared with it field for field. A nonce the site supplies is
ignored: the nonce is always the account's next nonce from the node, so a site
cannot replace one of the user's pending transactions or leave this one stuck
behind a gap. A request that cannot be populated — unreachable node, reverting
gas estimate — opens no window and is failed back to the site. Only one
transaction approval exists at a time: populating fixes the nonce, so a second
`eth_sendTransaction` arriving while one is unanswered is refused with
EIP-1193 code `-32002` rather than being populated at the same nonce. It opens
no window and takes no nonce, and the site can send it again once the pending
one is answered.
artifact can be compared with it field for field. A request that cannot be
populated — unreachable node, reverting gas estimate — opens no window and is
failed back to the site. Only one transaction approval exists at a time:
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
one is unanswered is refused with EIP-1193 code `-32002` rather than being
populated at the same nonce. It opens no window and takes no nonce, and the
site can send it again once the pending one is answered.
- **Elements**:
- "Transaction Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site origin (bold, scheme and port included) + "wants to send a
transaction"
- Site hostname (bold) + "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
- Contract: color dot + full address + etherscan link, token symbol label if
known; for a contract creation, the same sentence as on WaitTx
- Contract: color dot + full address + etherscan link (or "contract
creation"), token symbol label if known
- Value: amount in ETH (4 decimal places, USD in parentheses)
- Network fee (max): gas limit × fee per gas in ETH (4 decimal places, USD
in parentheses), with the gas limit and the fee per gas in gwei below it
@@ -1914,16 +1880,12 @@ view would leave a wallet one click from deletion.
- **When**: A connected website requests a message signature via
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
TxApproval, in a separate popup window. Only one exists per site at a time: a
further signature request, by any of these methods, from a site whose
signature request is still unanswered is refused with EIP-1193 code `-32002`
and opens no window.
TxApproval, in a separate popup window.
- **Elements**:
- "Signature Request" heading
- Phishing warning banner (shown when the hostname is on the phishing
blocklist)
- Site origin (bold, scheme and port included) + "wants you to sign a
message"
- Site hostname (bold) + "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
@@ -2168,17 +2130,6 @@ the log level and turns the banner on, and that is all it may ever do: it feeds
constant directly, so no runtime toggle in a release build can reach the
hardcoded test phrase.
At the raised log level the console also shows the wallet's addresses with their
balances and ENS names, the token contracts looked up, and a line for each
request made through `debugFetch` in `src/shared/log.js` (the explorer, the
price feed, the RPC calls a site makes, and the endpoint checks in settings) and
for its response. A request is logged by its HTTP method, the origin of its URL
(scheme, host and port) and, for a JSON-RPC call, the method name; the balance
refresh, the token lookup and a failed endpoint check in settings name the
endpoint by its origin too. The URL's path and query string, where RPC providers
put API keys, any user name and password in it, and the request body are never
logged.
### Key Decisions
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
-105
View File
@@ -45,82 +45,6 @@ but the review is broader than any of them.
# Completed Steps
- 2026-10-04: Debug mode no longer writes RPC API keys to the console
([#410](https://git.eeqj.de/sneak/AutistMask/issues/410)). `debugFetch` logged
every request's full URL and body, so an RPC endpoint with a key in its path
or query string printed that key on every request. It now logs the HTTP
method, the URL's origin and, for a JSON-RPC call, the method name. The
balance refresh and token lookup log the RPC endpoint by its origin too. A
failed RPC call's error line prints the error's short message, which names the
HTTP status, not its full message, which carries the request URL. A failed
endpoint check in settings names the endpoint by its origin, not the `fetch`
error's message, which carries the whole URL, password included, for a URL
with a user name and password. The README's DEBUG Mode Policy says what debug
mode logs.
- 2026-10-04: A site has at most one connection prompt and one signature prompt
open at a time ([#405](https://git.eeqj.de/sneak/AutistMask/issues/405)). Each
`eth_requestAccounts` or `personal_sign` call opened another approval window,
so a page calling in a loop could cover the screen with identical prompts. A
further request of the same kind from a site whose prompt is still unanswered
is now refused with EIP-1193 `-32002`, the code a second transaction already
gets, and opens no window. Signing by `personal_sign`, `eth_sign` and
`eth_signTypedData_v4` counts as one kind. Other sites are not affected, and
the site may ask again once the user has answered. A connection prompt whose
toolbar popup closed before it connected, and which nothing shows any more, is
shown again when the site asks again.
- 2026-10-04: A nonce the site supplies with `eth_sendTransaction` is ignored
([#404](https://git.eeqj.de/sneak/AutistMask/issues/404)). It was passed on to
the transaction, so a site could replace one of the user's pending
transactions (same nonce, higher fee) or leave the new one stuck behind a gap,
and the approval screen showed it as a bare number. `nonce` is no longer one
of the fields taken from the request in `src/shared/approvalTx.js`, so the
transaction always gets the account's next nonce from the node, and that is
the nonce the approval screen shows and the popup signs.
- 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 page's request is credited only to the site the browser says
sent it ([#407](https://git.eeqj.de/sneak/AutistMask/issues/407)). Where the
browser does not give the sender's origin (Firefox before 126), the background
used the tab's page, so a frame from another site would have been treated as
the site embedding it, and with no tab it used an origin the page wrote into
the message. It now uses the URL of the frame that sent the message, and
refuses the request with code 4100 when the browser gives neither. The content
script no longer writes an origin into the message.
- 2026-10-04: `make test` takes 8-13s on the shared build host, down from
17-25s, measured in alternating runs before and after the change
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
the tests (`tests/support/popupBoot.js`) resets jest's module registry so that
everything under `src/` loads fresh, and that also reloaded `ethers`,
`libsodium-wrappers-sumo`, `qrcode` and `ethereum-blockies-base64` every time.
Those four libraries are now loaded once per test file and handed to every
boot. No test or assertion changed.
- 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'
explorer reports disagree on a token's `decimals`, the Send screen showed the
stored figure while the confirmation screen it leads to said
`unknown (SYMBOL)`; both now say `unknown (SYMBOL)`, from one function in
`src/popup/views/send.js`. The confirmation screen's fee-unknown message names
its cause: for an unknown scale it says the wallet does not know how many
decimal places the token uses and that the transaction cannot be sent, instead
of asking the user to go back and try again, which cannot help. For any other
cause it is unchanged.
- 2026-10-04: A Uniswap V2 exact-out swap (Universal Router command `0x09`) is
decoded on the approval screen
([#283](https://git.eeqj.de/sneak/AutistMask/issues/283)). `decode()` in
@@ -136,35 +60,6 @@ but the review is broader than any of them.
`Token Out` and `Min. received` keep the output side's own token and figure.
V3 exact-out (`0x01`) is still not decoded.
- 2026-10-04: `make test` runs jest in three worker processes
([#426](https://git.eeqj.de/sneak/AutistMask/issues/426)). The `test` and
`test:verbose` scripts in `package.json` ran `jest --forceExit`, which starts
one worker per CPU core: about 47 processes and 7-8 GiB per run on the shared
48-core build host. They now pass `--maxWorkers=3`, and the suite takes 23-29s
there: inside the 30-second cap in `script/test`, which is unchanged, but not
by much, because `tests/persistedFieldContract.test.js` alone takes most of it
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). One or two workers
went past the cap. `make check`, the pre-commit hook and `script/cibuild` all
run the suite through these scripts.
- 2026-10-04: The error container on each dApp approval screen keeps its height
when an error appears
([#297](https://git.eeqj.de/sneak/AutistMask/issues/297)). `#approve-tx-error`
and `#approve-sign-error` reserved 20px, but their border and padding took
10px of it, so a one-line error grew them to 26px and pushed the buttons below
down 6px. They now reserve 30px. A new test in `tests/e2e/run.js` shows each
of the six password error containers on its own screen, empty and then with an
error, and fails if one changes height or the element below it moves. Some of
the longer messages these two containers can show still take two lines.
- 2026-10-04: A transaction with no `to` says "This transaction creates a new
contract. It has no recipient." on its recipient line and in its transaction
history row ([#250](https://git.eeqj.de/sneak/AutistMask/issues/250)). The
wait, success and error screens, the transaction detail view and the history
rows on Home, AddressDetail and AddressToken showed a blank address there,
with a colour dot whose colour was `undefined`; the approval screen showed
"(contract creation)". A transaction with a real `to` is unchanged.
- 2026-10-04: The Send and confirmation screens no longer show an ETH balance, a
token balance or a network fee below 0.000001 as zero
([#343](https://git.eeqj.de/sneak/AutistMask/issues/343)). The stored balances
+3 -5
View File
@@ -285,13 +285,11 @@ 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 origin (its scheme, host and port, for
example `https://app.example`) and the address that will be shared.
1. A popup appears showing the site's hostname 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. 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.
time.
When a connected site requests a transaction, a separate approval popup appears
showing the transaction details (from, to, value, data, network fee, network and
+2 -2
View File
@@ -6,8 +6,8 @@
"license": "GPL-3.0",
"private": true,
"scripts": {
"test": "jest --forceExit --maxWorkers=3",
"test:verbose": "jest --forceExit --maxWorkers=3 --verbose",
"test": "jest --forceExit",
"test:verbose": "jest --forceExit --verbose",
"build": "node build.js",
"lint": "eslint . && prettier --check .",
"fmt": "prettier --write .",
+5 -8
View File
@@ -1,14 +1,11 @@
#!/bin/sh
# script/test: run the test suite.
#
# jest runs three worker processes (package.json), not one per CPU core: on a
# many-core shared host one per core took gigabytes of RAM per run.
#
# The timeout bounds a hung suite; it is not a performance budget. On the busy
# shared build host the suite takes 8-13s with three workers, inside
# REPO_POLICIES' 20s budget. Inside the image the same suite also pays a cold
# jest cache and shares the runner with the rest of the build, which is not what
# that budget describes, so the Dockerfile raises the bound through
# The timeout bounds a hung suite; it is not a performance budget. On a
# developer host the suite finishes in about 8s and REPO_POLICIES' 30s cap is
# the bound. Inside the image the same suite also pays a cold jest cache and
# shares the runner with the rest of the build, which is not what that budget
# describes, so the Dockerfile raises the bound through
# AUTISTMASK_TEST_TIMEOUT. A cap a healthy suite can trip on a cold cache
# produces a red that means nothing, and teaches "just run it again".
set -eu
+62 -132
View File
@@ -57,13 +57,9 @@ 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, resolve } }
// Pending approval requests: { id: { origin, hostname, resolve } }
const pendingApprovals = {};
// One transaction approval at a time, wallet-wide.
@@ -87,8 +83,7 @@ const pendingApprovals = {};
// authority on a nonce the network has not accepted, which an abandoned
// approval then leaves a hole in.
//
// Sign approvals do not take this slot: a signature consumes no nonce. They are
// limited per site instead; see findPendingApproval().
// Sign approvals are not gated: a signature consumes no nonce.
//
// The slot is null when free, and otherwise the handle of the request holding
// it. Once that request has raised its approval the handle carries the
@@ -100,7 +95,7 @@ let txApprovalSlot = null;
// EIP-1474 "resource unavailable": the standard code for a request that is
// refused because another one is already pending.
const APPROVAL_PENDING_CODE = -32002;
const TX_APPROVAL_PENDING_CODE = -32002;
// True at every moment this can be sent: the slot is taken immediately before
// the transaction is populated, so the other request is either being prepared
@@ -137,22 +132,6 @@ function releaseTxApprovalSlotFor(approvalId) {
}
}
// One site-connection approval and one sign approval per site at a time: a
// page that asks again before the user has answered is refused with the code
// above instead of opening another window, so it cannot bury the user in
// prompts. The pending approval itself holds the place, so a caller must test
// this and raise its approval with nothing awaited in between.
function findPendingApproval(origin, type) {
return Object.values(pendingApprovals).find(
(approval) => approval.origin === origin && approval.type === type,
);
}
const APPROVAL_PENDING_MESSAGE =
"AutistMask is already waiting for your answer to a request of this kind" +
" from this site, so this one was not shown. Please answer that one," +
" then send this one again.";
// Nonces this worker has already handed to the node, per chain and address.
// This is the wallet's own knowledge that a nonce is spent, and it is checked
// before a broadcast rather than after: a node's pending count can lag a
@@ -305,12 +284,7 @@ async function proxyRpc(method, params) {
return json.result;
}
// The site-connection approval the toolbar popup is set to open, or null while
// it opens the wallet. Set only by resetPopupUrl() and showInToolbarPopup().
let toolbarPopupApprovalId = null;
function resetPopupUrl() {
toolbarPopupApprovalId = null;
if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" });
}
@@ -485,23 +459,12 @@ 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) {
function requestApproval(origin, hostname) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, resolve, type: "site" };
pendingApprovals[id] = { id, origin, hostname, resolve };
if (actionNs && typeof actionNs.openPopup === "function") {
showInToolbarPopup(id);
} else {
openApprovalWindow(id);
}
});
}
// Show a site-connection approval in the toolbar popup, or in a separate popup
// window when the browser will not open the toolbar popup.
function showInToolbarPopup(id) {
toolbarPopupApprovalId = id;
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
@@ -513,6 +476,10 @@ function showInToolbarPopup(id) {
} catch {
openApprovalWindow(id);
}
} else {
openApprovalWindow(id);
}
});
}
// Open a tx-approval popup and return a promise that resolves with txHash or error.
@@ -528,12 +495,13 @@ function showInToolbarPopup(id) {
// 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, approvedTx, approvedFrom, slot) {
function requestTxApproval(origin, hostname, approvedTx, approvedFrom, slot) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
hostname,
approvedTx,
approvedFrom,
resolve,
@@ -549,12 +517,13 @@ function requestTxApproval(origin, 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, signParams, approvedFrom) {
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
return new Promise((resolve) => {
const id = crypto.randomUUID();
pendingApprovals[id] = {
id,
origin,
hostname,
signParams,
approvedFrom,
resolve,
@@ -632,11 +601,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, origin) {
async function rememberSiteChoice(field, address, hostname) {
await updateState((s) => {
if (!s[field][address]) s[field][address] = [];
if (!s[field][address].includes(origin)) {
s[field][address].push(origin);
if (!s[field][address].includes(hostname)) {
s[field][address].push(hostname);
}
});
}
@@ -649,11 +618,12 @@ 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(origin)) {
if (denied.includes(hostname)) {
return {
error: {
code: 4001,
@@ -664,50 +634,25 @@ async function handleConnectionRequest(origin) {
// Check allowed list or in-memory connected
if (
allowed.includes(origin) ||
allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]
) {
return { result: [activeAddress] };
}
const pending = findPendingApproval(origin, "site");
if (pending) {
// A toolbar popup that closed before it connected leaves its prompt
// pending, and once the toolbar popup is set to open something else
// nothing shows that prompt: the site would be refused until the
// address changed. Show it again. A prompt in a window or in a
// connected popup is settled when that closes, and one the toolbar
// popup is still set to open is a click away, so those are left alone.
if (
actionNs &&
typeof actionNs.openPopup === "function" &&
!pending.windowId &&
!pending.portConnected &&
toolbarPopupApprovalId !== pending.id
) {
showInToolbarPopup(pending.id);
}
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
// Open approval popup
const decision = await requestApproval(origin);
const decision = await requestApproval(origin, hostname);
if (decision.approved) {
if (decision.remember) {
await rememberSiteChoice("allowedSites", activeAddress, origin);
await rememberSiteChoice("allowedSites", activeAddress, hostname);
} else {
connectedSites[origin + ":" + activeAddress] = true;
}
return { result: [activeAddress] };
} else {
if (decision.remember) {
await rememberSiteChoice("deniedSites", activeAddress, origin);
await rememberSiteChoice("deniedSites", activeAddress, hostname);
}
return {
error: {
@@ -753,9 +698,10 @@ 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(origin) ||
allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]
) {
return { result: [activeAddress] };
@@ -785,9 +731,10 @@ 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(origin) &&
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -859,9 +806,10 @@ 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(origin) ||
allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress];
if (!isConnected || !activeAddress) {
return { result: [] };
@@ -887,9 +835,10 @@ 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(origin) &&
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -919,16 +868,9 @@ async function handleRpc(method, params, origin) {
"Only proceed if you fully understand what you are signing.";
}
if (findPendingApproval(origin, "sign")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestSignApproval(
origin,
hostname,
signParams,
activeAddress,
);
@@ -942,9 +884,10 @@ 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(origin) &&
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -960,16 +903,9 @@ async function handleRpc(method, params, origin) {
},
};
}
if (findPendingApproval(origin, "sign")) {
return {
error: {
code: APPROVAL_PENDING_CODE,
message: APPROVAL_PENDING_MESSAGE,
},
};
}
const decision = await requestSignApproval(
origin,
hostname,
signParams,
activeAddress,
);
@@ -1010,9 +946,10 @@ 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(origin) &&
!allowed.includes(hostname) &&
!connectedSites[origin + ":" + activeAddress]
) {
return { error: { code: 4100, message: "Unauthorized" } };
@@ -1039,7 +976,7 @@ async function handleSendTransaction(params, origin) {
if (!slot) {
return {
error: {
code: APPROVAL_PENDING_CODE,
code: TX_APPROVAL_PENDING_CODE,
message: TX_APPROVAL_PENDING_MESSAGE,
},
};
@@ -1086,6 +1023,7 @@ async function handleSendTransaction(params, origin) {
const decision = await requestTxApproval(
origin,
hostname,
approvedTx,
activeAddress,
slot,
@@ -1159,9 +1097,10 @@ 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(origin) ||
(allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]);
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
@@ -1174,7 +1113,7 @@ async function broadcastAccountsChanged() {
}
// Tell every open tab of a site Settings removed that it has no account.
async function broadcastSiteRemoved(origin) {
async function broadcastSiteRemoved(hostname) {
let tabs;
try {
tabs = await tabsQuery({});
@@ -1182,7 +1121,7 @@ async function broadcastSiteRemoved(origin) {
return;
}
for (const tab of tabs) {
if (!tab.url || new URL(tab.url).origin !== origin) continue;
if (!tab.url || extractHostname(tab.url) !== hostname) continue;
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
@@ -1349,29 +1288,18 @@ if (windowsNs && windowsNs.onRemoved) {
// Listen for messages from content scripts and popup
runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_RPC") {
// The origin is the one the browser reports for the sender, never one
// the message carries. Firefox before 126 gives no sender.origin, so
// the origin of sender.url is used: the frame that sent the message,
// not the tab's page, which may be another site embedding that
// frame. With neither, the request is refused.
let trustedOrigin = sender.origin;
if (!trustedOrigin && sender.url) {
// 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.url).origin;
trustedOrigin = new URL(sender.tab.url).origin;
} catch {
// an unparseable URL leaves the origin unknown
// keep fallback
}
}
if (!trustedOrigin) {
sendResponse({
error: {
code: 4100,
message:
"The wallet could not tell which site sent this request.",
},
});
return false;
}
handleRpc(msg.method, msg.params, trustedOrigin)
.then((response) => {
sendResponse(response);
@@ -1409,7 +1337,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
const approval = pendingApprovals[msg.id];
if (approval) {
const resp = { origin: approval.origin };
const resp = {
hostname: approval.hostname,
origin: approval.origin,
};
if (approval.type === "tx") {
resp.type = "tx";
// The populated transaction, and the address it was raised
@@ -1424,9 +1355,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
resp.approvedFrom = approval.approvedFrom;
}
// Flag if the requesting domain is on the phishing blocklist.
resp.isPhishingDomain = isPhishingDomain(
extractHostname(approval.origin),
);
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
sendResponse(resp);
} else {
sendResponse(null);
@@ -1760,22 +1689,23 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
sendResponse(
Object.keys(connectedSites).map((key) =>
key.slice(0, key.lastIndexOf(":")),
extractHostname(key.slice(0, key.lastIndexOf(":"))),
),
);
return false;
}
// 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.
// 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.
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
for (const key of Object.keys(connectedSites)) {
if (key.slice(0, key.lastIndexOf(":")) === msg.origin) {
const origin = key.slice(0, key.lastIndexOf(":"));
if (extractHostname(origin) === msg.hostname) {
delete connectedSites[key];
}
}
broadcastSiteRemoved(msg.origin);
broadcastSiteRemoved(msg.hostname);
return false;
}
});
+1
View File
@@ -30,6 +30,7 @@ window.addEventListener("message", (event) => {
id,
method,
params,
origin: location.origin,
})
.then((response) => {
if (response) {
+10 -8
View File
@@ -698,13 +698,15 @@
You do not have enough ETH to pay the network fee for this
transfer. Please add ETH to this address and try again.
</div>
<!-- Its sentence names why the fee could not be estimated,
so show() in confirmTx.js sets it. -->
<div
id="confirm-fee-unknown-error"
class="mb-2 border border-border border-dashed p-2 text-xs"
style="visibility: hidden"
></div>
>
The network fee could not be estimated, so this transaction
cannot be checked against your balance. Please go back and
try again.
</div>
<div class="mb-2">
<label class="block mb-1 text-xs">Password</label>
<input
@@ -1561,7 +1563,7 @@
with extreme caution.
</div>
<p class="mb-2">
<span id="approve-tx-origin" class="font-bold"></span>
<span id="approve-tx-hostname" class="font-bold"></span>
wants to send a transaction.
</p>
@@ -1631,7 +1633,7 @@
</div>
<div
id="approve-tx-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="flex justify-between">
@@ -1662,7 +1664,7 @@
funds. Proceed with extreme caution.
</div>
<p class="mb-2">
<span id="approve-sign-origin" class="font-bold"></span>
<span id="approve-sign-hostname" class="font-bold"></span>
wants you to sign a message.
</p>
@@ -1708,7 +1710,7 @@
</div>
<div
id="approve-sign-error"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.875rem]"
class="text-xs mb-2 border border-border border-dashed p-1 min-h-[1.25rem]"
style="visibility: hidden"
></div>
<div class="flex justify-between">
@@ -1740,7 +1742,7 @@
</div>
<div class="mb-3">
<p class="mb-2">
<span id="approve-origin" class="font-bold"></span>
<span id="approve-hostname" class="font-bold"></span>
wants to connect to your wallet.
</p>
<div class="text-xs text-muted mb-1">
+5 -2
View File
@@ -3,7 +3,7 @@ const {
showView,
showFlash,
balanceLinesForAddress,
txCounterpartyHtml,
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -233,13 +233,16 @@ function renderTransactions(txs) {
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
+5 -2
View File
@@ -6,7 +6,7 @@ const {
showView,
showFlash,
flashCopyFeedback,
txCounterpartyHtml,
addressDotHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -309,13 +309,16 @@ function renderTransactions(txs) {
// is shown whole; the title or ENS name, where there is one, names
// it on the line above rather than replacing it.
const nameStr = escapeHtml(title || ensName || "");
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, nameStr, amountStr);
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${nameStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
+4 -5
View File
@@ -1,7 +1,6 @@
const {
$,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
showView,
showError,
@@ -306,7 +305,7 @@ function showTxApproval(details) {
};
}
$("approve-tx-origin").textContent = details.origin;
$("approve-tx-hostname").textContent = details.hostname;
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
// Show token symbol next to contract address if known
@@ -319,7 +318,7 @@ function showTxApproval(details) {
toHtml += approvalAddressHtml(toAddr);
$("approve-tx-to").innerHTML = toHtml;
} else {
$("approve-tx-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
$("approve-tx-to").innerHTML = escapeHtml("(contract creation)");
}
const ethValueFormatted = formatTxValue(
@@ -645,7 +644,7 @@ function showSignApproval(details) {
pendingSignParams = sp;
pendingSignFrom = details.approvedFrom;
$("approve-sign-origin").textContent = details.origin;
$("approve-sign-hostname").textContent = details.hostname;
$("approve-sign-from").innerHTML = approvalAddressHtml(
details.approvedFrom,
);
@@ -732,7 +731,7 @@ async function show(id) {
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-origin").textContent = details.origin;
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
+2 -16
View File
@@ -198,19 +198,6 @@ function show(txInfo) {
$("confirm-amount-fee-error").classList.toggle("hidden", isErc20);
$("confirm-gas-error").classList.toggle("hidden", !isErc20);
// The fee-unknown message names its cause, which is also known here.
// Without the token's scale estimateGas() cannot encode the transfer, so
// the estimate fails every time and going back cannot help; any other
// failure may clear on a retry.
$("confirm-fee-unknown-error").textContent =
isErc20 && txInfo.tokenDecimals == null
? "The network fee could not be estimated, because this wallet" +
" does not know how many decimal places this token uses, so" +
" this transaction cannot be sent."
: "The network fee could not be estimated, so this transaction" +
" cannot be checked against your balance. Please go back and" +
" try again.";
renderValidation(txInfo);
// Reset password field and error
@@ -257,8 +244,7 @@ function renderValidation(txInfo) {
});
// Messages carrying the user's own numbers are built here; the fixed
// sentences live in the reserved elements in index.html, except the
// fee-unknown one, which show() sets.
// sentences live in the reserved elements in index.html.
const messages = [];
if (codes.includes(CODES.AMOUNT_INVALID)) {
messages.push("Please enter a valid amount to send.");
@@ -395,7 +381,7 @@ async function estimateGas(txInfo) {
feeWei = gasCostWei;
renderValidation(txInfo);
} catch (e) {
log.errorf("gas estimation failed:", e.shortMessage || e.message);
log.errorf("gas estimation failed:", e.message);
if (pendingTx !== txInfo) return;
$("confirm-fee-amount").textContent = "Unable to estimate";
setVisible("confirm-fee-reserve", false);
-25
View File
@@ -431,29 +431,6 @@ function addressTitle(address, wallets) {
return null;
}
// What every recipient line and history row says for a transaction with no
// `to`. Such a transaction creates a contract, so there is no address to show,
// and a blank line on these screens reads as a rendering fault.
const CONTRACT_CREATION_TEXT =
"This transaction creates a new contract. It has no recipient.";
// The last two lines of a transaction history row: the counterparty's colour
// dot and name beside the amount, then its full address. A contract creation
// the user sent has no counterparty (its `to` is ""), so its row has the
// amount alone and the contract creation sentence in place of the address.
function txCounterpartyHtml(address, nameHtml, amountHtml) {
if (!address) {
return (
`<div class="flex justify-between"><span></span><span>${amountHtml}</span></div>` +
`<div>${escapeHtml(CONTRACT_CREATION_TEXT)}</div>`
);
}
return (
`<div class="flex justify-between"><span class="flex items-center">${addressDotHtml(address)}${nameHtml}</span><span>${amountHtml}</span></div>` +
`<div class="am-address">${escapeHtml(address)}</div>`
);
}
// Render an address with color dot, optional ENS name, optional title,
// and optional truncation. Title and ENS are shown as bold labels above
// the full address.
@@ -666,8 +643,6 @@ module.exports = {
escapeHtml,
displaySymbol,
addressTitle,
CONTRACT_CREATION_TEXT,
txCounterpartyHtml,
formatAddressHtml,
renderAddressHtml,
copyableHtml,
+4 -2
View File
@@ -6,7 +6,6 @@ const {
isoDate,
timeAgo,
addressDotHtml,
txCounterpartyHtml,
addressTitle,
escapeHtml,
displaySymbol,
@@ -123,13 +122,16 @@ function renderHomeTxList(ctx) {
// names it on the line above rather than replacing it.
const title = addressTitle(counterparty, state.wallets);
const titleStr = title ? escapeHtml(title) : "";
const addrStr = escapeHtml(counterparty);
const dot = addressDotHtml(counterparty);
const err = tx.isError ? " (failed)" : "";
const opacity = tx.isError ? " opacity:0.5;" : "";
const ago = escapeHtml(timeAgo(tx.timestamp));
const iso = escapeHtml(isoDate(tx.timestamp));
html += `<div class="home-tx-row py-2 border-b border-border-light text-xs cursor-pointer hover:bg-hover" data-tx="${i}" style="${opacity}">`;
html += `<div class="flex justify-between"><span class="text-muted" title="${iso}">${ago}</span><span>${dirLabel}${err}</span></div>`;
html += txCounterpartyHtml(counterparty, titleStr, amountStr);
html += `<div class="flex justify-between"><span class="flex items-center">${dot}${titleStr}</span><span>${amountStr}</span></div>`;
html += `<div class="am-address">${addrStr}</div>`;
html += `</div>`;
i++;
}
+52 -52
View File
@@ -146,50 +146,6 @@ function renderSendTokenSelect(addr) {
}
}
// The token balance and scale the Send screen states and hands the
// confirmation screen, so the two screens describe the holding the same way.
//
// The scale is resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's tracked
// tokens, then the explorer. The stored tokenBalances[].decimals is the
// explorer's own answer alone, so reading it raw carries a null forward for a
// token the wallet does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as an unestimable
// fee. Unsendable, over a scale that was never in doubt
// (https://git.eeqj.de/sneak/AutistMask/issues/349). Still null when nothing
// knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that adds
// explorerDecimals()'s cross-address check, so a contract two addresses report
// different scales for answers null rather than picking one. That check has to
// apply here, because this scale encodes the transfer — it is carried forward
// so the transfer is encoded with the number the user read rather than with
// whatever the contract answers at signing time (see
// src/shared/transferAmount.js). balances.js is formatting one explorer row at
// fetch time and cannot consult a state it is in the middle of replacing.
//
// The two resolutions can therefore differ, and where they do, the stored
// `balance` is a quantity computed at a scale this screen has just declined to
// stand behind. Stating it would leave validateTransfer() checking the amount
// against a number the wallet does not vouch for, so it is withdrawn: unknown
// scale means unknown balance. It is null rather than "0": both screens state
// an unknown balance as unknown, and validateTransfer() treats it as no balance
// to spend from, which is the fail-closed side of an amount nobody can check.
// Only a stored quantity is withdrawn: the "0" for a token that has no row at
// all is an absence of holdings, which is true at every scale.
function tokenBalanceAndDecimals(addr, token) {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
if (!tb) return { tokenBalance: "0", tokenDecimals };
if (tokenDecimals === null) return { tokenBalance: null, tokenDecimals };
return { tokenBalance: tb.balance ?? null, tokenDecimals };
}
function updateSendBalance() {
const addr = currentAddress();
if (!addr) return;
@@ -206,16 +162,18 @@ function updateSendBalance() {
truncateAmountNeverZero(addr.balance || "0") +
" ETH";
} else {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
const symbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
// A null balance is a holding whose scale is unknown. Saying a figure
// for it would be a claim about the amount, so it reads as the
// confirmation screen's balance line reads it; the send itself is
// A null balance is a holding whose scale nothing knows. Saying "0"
// for it would be a claim about the amount; the send itself is
// refused later by transferAmountUnits() for the same missing scale.
const bal = tokenBalanceAndDecimals(addr, token).tokenBalance;
const bal = tb ? tb.balance : "0";
$("send-balance").textContent =
bal == null
? "Current balance: unknown (" + symbol + ")"
@@ -282,17 +240,59 @@ function init(_ctx) {
let tokenSymbol = null;
let tokenBalance = null;
// The scale the amount and the balance below are rendered at, carried
// forward so the transfer is encoded with the number the user read
// rather than with whatever the contract answers at signing time. See
// src/shared/transferAmount.js.
let tokenDecimals = null;
if (token !== "ETH") {
const tb = (addr.tokenBalances || []).find(
(t) => t.address.toLowerCase() === token.toLowerCase(),
);
tokenSymbol = resolveSymbol(
token,
addr.tokenBalances,
state.trackedTokens,
);
({ tokenBalance, tokenDecimals } = tokenBalanceAndDecimals(
addr,
token,
));
// null carried through rather than flattened to "0": the confirm
// screen states an unknown balance as unknown, and
// validateTransfer() treats it as no balance to spend from, which
// is the fail-closed side of an amount nobody can check.
tokenBalance = tb ? (tb.balance ?? null) : "0";
// Resolved the same way balances.js resolved the scale it
// DISPLAYED this token's balance at: bundled list, then the user's
// tracked tokens, then the explorer. The stored
// tokenBalances[].decimals is the explorer's own answer alone, so
// reading it raw carries a null forward for a token the wallet
// does know the scale of — and displayedDecimals() then throws
// inside estimateGas(), which the confirmation screen reports as
// an unestimable fee. Unsendable, over a scale that was never in
// doubt (https://git.eeqj.de/sneak/AutistMask/issues/349).
// Still null when nothing knows: no fallback.
//
// Resolved WITH `wallets`, which balances.js does not pass: that
// adds explorerDecimals()'s cross-address check, so a contract two
// addresses report different scales for answers null rather than
// picking one. That check has to apply here, because this value
// encodes a transfer; balances.js is formatting one explorer row
// at fetch time and cannot consult a state it is in the middle of
// replacing.
tokenDecimals = resolveTokenDecimals(token, {
trackedTokens: state.trackedTokens,
wallets: state.wallets,
});
// The two resolutions can therefore differ, and where they do, the
// stored `balance` is a quantity computed at a scale this screen
// has just declined to stand behind. Stating it would leave
// validateTransfer() checking the amount against a number the
// wallet does not vouch for, and — since the unknown-balance path
// is gated on the balance, not on the scale — would leave the
// fee-estimate failure as the only thing on the confirmation
// screen, which says nothing about decimals. Unknown scale means
// unknown balance. Only a stored quantity is withdrawn: the "0"
// for a token that has no row at all is an absence of holdings,
// which is true at every scale.
if (tb && tokenDecimals === null) tokenBalance = null;
}
ctx.showConfirmTx({
+22 -31
View File
@@ -16,12 +16,7 @@ const {
} = require("../dustThreshold");
const { state, saveState, currentNetwork } = require("../../shared/state");
const { onChainSwitch } = require("../../shared/chainSwitch");
const {
log,
debugFetch,
urlOrigin,
setRuntimeDebug,
} = require("../../shared/log");
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
const deleteWallet = require("./deleteWallet");
const showPhrase = require("./showPhrase");
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
@@ -39,35 +34,35 @@ const { notify, sendMessage } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
// 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) {
// 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) {
const container = $(containerId);
const unique = [...new Set(origins)];
const unique = [...new Set(hostnames)];
if (unique.length === 0) {
container.innerHTML = '<p class="text-xs text-muted">None</p>';
return;
}
let html = "";
unique.forEach((origin) => {
unique.forEach((hostname) => {
html += `<div class="flex justify-between items-center text-xs py-1 border-b border-border-light">`;
// An origin the URL parser produced cannot carry a delimiter, so
// A hostname 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(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 += `<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 += `</div>`;
});
container.innerHTML = html;
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
btn.addEventListener("click", () => onRemove(btn.dataset.origin));
btn.addEventListener("click", () => onRemove(btn.dataset.hostname));
});
}
// Drop a site origin from a remembered site list under every address.
function forgetOrigin(siteMap, origin) {
// Drop a hostname from a remembered site list under every address.
function forgetHostname(siteMap, hostname) {
for (const addr of Object.keys(siteMap)) {
siteMap[addr] = siteMap[addr].filter((o) => o !== origin);
siteMap[addr] = siteMap[addr].filter((h) => h !== hostname);
if (siteMap[addr].length === 0) {
delete siteMap[addr];
}
@@ -77,16 +72,16 @@ function forgetOrigin(siteMap, origin) {
// 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(origin) {
forgetOrigin(state.allowedSites, origin);
async function removeAllowedSite(hostname) {
forgetHostname(state.allowedSites, hostname);
await saveState();
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
await renderSiteLists();
}
// Removing a denied site only forgets the refusal; it connects nothing.
async function removeDeniedSite(origin) {
forgetOrigin(state.deniedSites, origin);
async function removeDeniedSite(hostname) {
forgetHostname(state.deniedSites, hostname);
await saveState();
await renderSiteLists();
}
@@ -277,11 +272,8 @@ function init(ctx) {
showFlash("Wrong network: expected " + net.name + ".");
return;
}
} catch {
// Not the error's message: fetch puts the whole URL, password and
// key included, in the message of the error it throws for a URL
// with a user name and password or one it cannot parse.
log.errorf("RPC validation fetch failed:", urlOrigin(url));
} catch (e) {
log.errorf("RPC validation fetch failed:", e.message);
showFlash("Could not reach endpoint.");
return;
}
@@ -303,9 +295,8 @@ function init(ctx) {
showFlash("Endpoint returned HTTP " + resp.status + ".");
return;
}
} catch {
// Not the error's message, as for the RPC check above.
log.errorf("Blockscout validation failed:", urlOrigin(url));
} catch (e) {
log.errorf("Blockscout validation failed:", e.message);
showFlash("Could not reach endpoint.");
return;
}
+1 -7
View File
@@ -7,7 +7,6 @@ const {
showFlash,
flashCopyFeedback,
addressTitle,
CONTRACT_CREATION_TEXT,
addressDotHtml,
escapeHtml,
isoDate,
@@ -95,18 +94,13 @@ function render() {
$("tx-detail-hash").innerHTML = txHashHtml(tx.hash);
const fromTitle = addressTitle(tx.from, state.wallets);
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-from").innerHTML = txAddressHtml(
tx.from,
tx.fromEns,
fromTitle,
);
// A contract creation has no recipient: transactions.js gives it `to: ""`.
if (tx.to) {
const toTitle = addressTitle(tx.to, state.wallets);
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
} else {
$("tx-detail-to").innerHTML = escapeHtml(CONTRACT_CREATION_TEXT);
}
// Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol);
+3 -8
View File
@@ -4,7 +4,6 @@ const {
$,
showView,
addressTitle,
CONTRACT_CREATION_TEXT,
escapeHtml,
renderAddressHtml,
attachCopyHandlers,
@@ -59,10 +58,7 @@ function endWait() {
}
}
// A contract creation reaches these screens with `to` as "" (approval.js
// writes `to: toAddr || ""`).
function toAddressHtml(address) {
if (!address) return escapeHtml(CONTRACT_CREATION_TEXT);
const title = addressTitle(address, state.wallets);
return renderAddressHtml(address, { title });
}
@@ -133,7 +129,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
// failed — which matters most on a resumed wait, where the
// first poll is already past the deadline.
answered = false;
log.errorf("poll receipt failed:", e.shortMessage || e.message);
log.errorf("poll receipt failed:", e.message);
}
// The lookup is async: the wait may have ended while it was in
// flight, in which case this result must not touch the view.
@@ -206,9 +202,8 @@ function restoreWait() {
if (!info || typeof info !== "object" || Array.isArray(info)) return false;
// A string is the whole requirement: the empty string is what a
// contract-deployment approval persists (approval.js writes `to: toAddr
// || ""`), an empty `to` renders as a contract creation and an empty
// amount renders harmlessly, so refusing it would abandon a wait the live
// path itself created.
// || ""`), and both fields render harmlessly when empty, so refusing it
// would abandon a wait the live path itself created.
if (typeof info.to !== "string") return false;
if (typeof info.amount !== "string") return false;
if (typeof w.broadcastTime !== "number" || !isFinite(w.broadcastTime)) {
+2 -2
View File
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
});
}
} catch (e) {
log.errorf("contract check failed:", e.shortMessage || e.message);
log.errorf("contract check failed:", e.message);
}
// Skip tx count check for contracts — they may legitimately have
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
});
}
} catch (e) {
log.errorf("tx count check failed:", e.shortMessage || e.message);
log.errorf("tx count check failed:", e.message);
}
}
+1 -5
View File
@@ -51,15 +51,11 @@ const POPULATE_TIMEOUT_MS = 20000;
// passed to ethers: the object is page-controlled, and a future ethers that
// learns to carry a new transaction field must not start picking one up out of
// it without this module knowing.
//
// The nonce is not taken from the page; it is always the account's next nonce
// from the network. A page that chose it could replace one of the user's
// pending transactions (the same nonce at a higher fee) or leave this one stuck
// behind a gap (a nonce above the next one).
const REQUEST_FIELDS = [
"to",
"value",
"data",
"nonce",
"gasLimit",
"gasPrice",
"maxFeePerGas",
+5 -8
View File
@@ -10,7 +10,7 @@ const {
} = require("ethers");
const { ERC20_ABI } = require("./constants");
const { NETWORKS } = require("./networks");
const { log, debugFetch, urlOrigin } = require("./log");
const { log, debugFetch } = require("./log");
const { deriveAddressFromXpub } = require("./wallet");
const { TOKEN_BY_ADDRESS } = require("./tokenList");
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
@@ -203,7 +203,7 @@ async function refreshBalances(
trackedTokens,
networkId,
) {
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
log.debugf("refreshBalances start, rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const updates = [];
@@ -246,7 +246,7 @@ async function refreshBalances(
log.errorf(
"ENS reverse failed",
addr.address,
e.shortMessage || e.message,
e.message,
);
// Keep existing addr.ensName if we had one
}),
@@ -280,7 +280,7 @@ async function refreshBalances(
// Look up token metadata from its contract.
// Calls symbol() and decimals() to verify it implements ERC-20.
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
log.debugf("lookupTokenInfo", contractAddress, "rpc:", urlOrigin(rpcUrl));
log.debugf("lookupTokenInfo", contractAddress, "rpc:", rpcUrl);
const provider = getProvider(rpcUrl, networkId);
const contract = new Contract(contractAddress, ERC20_ABI, provider);
@@ -305,10 +305,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
name = await contract.name();
log.debugf("name() =", name);
} catch (e) {
log.warnf(
"name() failed, using symbol as name:",
e.shortMessage || e.message,
);
log.warnf("name() failed, using symbol as name:", e.message);
name = symbol;
}
+1 -5
View File
@@ -42,11 +42,7 @@ async function resolveEnsName(address, rpcUrl, networkId) {
setCache(address, name);
return name;
} catch (e) {
log.errorf(
"ENS reverse lookup failed",
address,
e.shortMessage || e.message,
);
log.errorf("ENS reverse lookup failed", address, e.message);
// Don't cache failures — let subsequent lookups retry
return null;
}
+5 -25
View File
@@ -42,34 +42,14 @@ const log = {
},
};
// The origin (scheme, host and port) of a URL, for logging in place of the
// URL: RPC providers put API keys in the path or the query string, and a URL
// can carry a user name and password, which the origin leaves out. A URL that
// does not parse gives "", so logging never stops a request.
function urlOrigin(url) {
try {
return new URL(url).origin;
} catch {
return "";
}
}
// Fetch wrapper that debug-logs every request and response. It logs the
// URL's origin and, for a JSON-RPC body, the method name: never the full URL
// or body, which can carry an API key or a signed transaction.
// Fetch wrapper that debug-logs every request and response.
async function debugFetch(url, opts) {
const method = (opts && opts.method) || "GET";
const origin = urlOrigin(url);
let rpcMethod = "";
try {
rpcMethod = JSON.parse(opts.body).method || "";
} catch {
// no body, or a body that is not JSON
}
log.debugf("fetch →", method, origin, rpcMethod);
const body = opts && opts.body;
log.debugf("fetch →", method, url, body || "");
const resp = await fetch(url, opts);
log.debugf("fetch ←", resp.status, origin);
log.debugf("fetch ←", resp.status, url);
return resp;
}
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
+10 -11
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 origin list in the site maps
// below (`o !== origin` filters, `list.includes(origin)` in the background).
// src/shared/transactions.js) and each address's hostname list in the site maps
// below (`h !== host` filters, `list.includes(hostname)` 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 an origin
// with a malformed entry DROPPED rather than repaired. A number in a hostname
// 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,22 +116,21 @@ function textList(value) {
return value.filter((entry) => typeof entry === "string");
}
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
// full scheme://host[:port] of a site.
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
//
// 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 origin
// level below it. saveState() merges these maps per key and then per hostname
// 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(origin)` gate, dereference it the same way; on that last
// `allowed.includes(hostname)` 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 origins is dropped entirely: it
// An address key whose value is not a list of hostnames 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
@@ -144,9 +143,9 @@ function siteMap(value) {
if (!isRecord(value)) return out;
for (const address of Object.keys(value)) {
if (address === "__proto__") continue;
const origins = textList(value[address]);
if (origins.length === 0) continue;
defineOwn(out, address, origins);
const hostnames = textList(value[address]);
if (hostnames.length === 0) continue;
defineOwn(out, address, hostnames);
}
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 ->
// origin list; networkEndpoints: networkId -> {rpcUrl, blockscoutUrl}) the
// hostname 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]: [origin, ...] }. The origin
// allowedSites/deniedSites: { [address]: [hostname, ...] }. The hostname
// list is itself membership, not a leaf — the background appends a newly
// 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
// 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
// different page. Merge it the same way wallets are merged: identity is the
// origin itself, so a merged pair is always equal and mergeItem is a no-op
// hostname itself, so a merged pair is always equal and mergeItem is a no-op
// pick.
function mergeOriginList(base, ours, theirs) {
function mergeHostnameList(base, ours, theirs) {
return mergeListByIdentity(
base,
ours,
theirs,
(origin) => origin,
(hostname) => hostname,
(b, o, t) => t,
);
}
function mergeSiteMap(base, ours, theirs) {
return mergeMapByKey(base, ours, theirs, mergeOriginList);
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
}
// 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 origin within each address's list), for the
// identical reason: the background appends a newly approved/denied origin
// by address key and then by hostname within each address's list), for the
// identical reason: the background appends a newly approved/denied hostname
// 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
-167
View File
@@ -1,167 +0,0 @@
// A nonce the page supplies is not used
// (https://git.eeqj.de/sneak/AutistMask/issues/404). With it a page could
// replace one of the user's pending transactions (the same nonce at a higher
// fee) or leave the new one stuck behind a gap, so the transaction is always
// given the account's next nonce from the network.
//
// Driven through the preparation the background runs on a page's
// eth_sendTransaction (src/shared/approvalTx.js) and the real approval screen,
// password and Confirm included, against a minimal DOM stub in the shape
// tests/approvalOrigin.test.js uses. The vault is mocked so that no password
// has to be hashed.
jest.mock("../src/shared/vault", () => ({
decryptWithPassword: jest.fn(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { Network, Transaction } = require("ethers");
const { state } = require("../src/shared/state");
const { decryptWithPassword } = require("../src/shared/vault");
const { prepareApprovalTx } = require("../src/shared/approvalTx");
const approval = require("../src/popup/views/approval");
// A well-known test phrase, and its first address.
const PHRASE = "test test test test test test test test test test test junk";
const FROM = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The account's next nonce, as the network reports it.
const NETWORK_NONCE = 7;
const network = {
getNetwork: async () => Network.from(1),
getTransactionCount: async () => NETWORK_NONCE,
estimateGas: async () => 21000n,
getFeeData: async () => ({
gasPrice: 2000000000n,
maxFeePerGas: 2000000000n,
maxPriorityFeePerGas: 1000000000n,
}),
};
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
listeners: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: (name, fn) => {
el.listeners[name] = el.listeners[name] || [];
el.listeners[name].push(fn);
},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
function click(id) {
return Promise.all((node(id).listeners.click || []).map((fn) => fn()));
}
// Open the transaction approval screen the way the popup does: the background
// hands over the populated transaction and show() draws it. Returns every
// message the screen sends to the background. The background's answer to the
// signed transaction does not matter here; a retryable refusal keeps the
// screen where it is.
async function openTxApproval(approvedTx) {
const sent = [];
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" }, close: () => {} };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
sent.push(msg);
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") {
return reply({ error: "Not sent.", retryable: true });
}
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx,
});
},
};
state.activeAddress = FROM;
state.wallets = [
{
type: "hd",
name: "Wallet 1",
xpub: "xpub-wallet-1",
encryptedSecret: "encrypted-secret-1",
nextIndex: 1,
addresses: [{ address: FROM, balance: "0", tokenBalances: [] }],
},
];
approval.init({});
await approval.show(1);
return sent;
}
test("a page's nonce is replaced by the network's, on screen and in the signed transaction", async () => {
// What the background does with the page's request before it opens the
// approval window.
const approvedTx = await prepareApprovalTx(network, FROM, {
from: FROM,
to: RECIPIENT,
value: "0x0",
data: "0x",
nonce: "0x2",
});
const sent = await openTxApproval(approvedTx);
expect(node("approve-tx-nonce").textContent).toBe("7");
decryptWithPassword.mockResolvedValue(PHRASE);
node("approve-tx-password").value = "any password";
await click("btn-approve-tx");
const response = sent.find((msg) => msg.type === "AUTISTMASK_TX_RESPONSE");
expect(Transaction.from(response.rawSignedTx).nonce).toBe(NETWORK_NONCE);
});
-140
View File
@@ -1,140 +0,0 @@
// The connection, transaction and signature prompts name the site by its full
// origin, scheme and port included, not by its bare hostname
// (https://git.eeqj.de/sneak/AutistMask/issues/402). A page served over http,
// or on another port, of a host the user trusts over https must not raise a
// prompt that reads as that trusted site.
//
// Driven against a minimal DOM stub in the shape
// tests/contractCreation.test.js uses.
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
// The site asking, in cleartext and on a port, which is what the hostname
// alone, dapp.example, used to hide.
const ORIGIN = "http://dapp.example:8080";
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// Open the prompt the background describes with `details`, the way the popup
// does: it asks for the approval and show() draws it.
async function openApproval(details) {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
origin: ORIGIN,
isPhishingDomain: false,
approvedFrom: FROM,
...details,
});
},
};
approval.init({});
await approval.show(1);
}
beforeEach(() => {
state.wallets = [];
state.activeAddress = FROM;
state.viewData = {};
state.viewStack = [];
state.currentView = null;
});
test("the connection prompt shows the origin", async () => {
await openApproval({});
expect(node("approve-origin").textContent).toBe(ORIGIN);
});
test("the transaction prompt shows the origin", async () => {
await openApproval({
type: "tx",
approvedTx: {
type: 2,
from: FROM,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to: RECIPIENT,
value: "0x0",
data: "0x",
accessList: [],
},
});
expect(node("approve-tx-origin").textContent).toBe(ORIGIN);
});
test("the signature prompt shows the origin", async () => {
await openApproval({
type: "sign",
// "Hello" as the hex a dApp passes to personal_sign.
signParams: {
method: "personal_sign",
message: "0x48656c6c6f",
from: FROM,
},
});
expect(node("approve-sign-origin").textContent).toBe(ORIGIN);
});
+2 -2
View File
@@ -121,7 +121,7 @@ describe("prepareApprovalTx", () => {
);
});
test("keeps a gas limit and fee the request did fix, but not its nonce", async () => {
test("keeps a nonce, gas limit and fee the request did fix", async () => {
const approved = await prepareApprovalTx(
providerWith(),
signer.address,
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
maxPriorityFeePerGas: "0x3b9aca00",
},
);
expect(approved.nonce).toBe("0x7");
expect(approved.nonce).toBe("0x2");
expect(approved.gasLimit).toBe("0x30d40");
expect(approved.maxFeePerGas).toBe("0x12a05f200");
});
+20 -358
View File
@@ -39,6 +39,7 @@ 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/";
@@ -74,22 +75,6 @@ const NONCE = 7;
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
// An EIP-712 document as a dApp passes it to eth_signTypedData_v4. The
// background only carries it to the approval screen, so a small one does.
const TYPED_DATA = JSON.stringify({
domain: { name: "AutistMask Test", version: "1", chainId: 1 },
primaryType: "Note",
types: {
EIP712Domain: [
{ name: "name", type: "string" },
{ name: "version", type: "string" },
{ name: "chainId", type: "uint256" },
],
Note: [{ name: "contents", type: "string" }],
},
message: { contents: "Hello AutistMask" },
});
// The transaction the background populates and the approval screen displays.
// The nonce is a parameter because the duplicate case turns on two artifacts
// differing in a field the dApp fixed nothing for.
@@ -214,7 +199,7 @@ function loadBackground(options) {
networkId: "mainnet",
rpcUrl: "https://rpc.invalid",
activeAddress: signer.address,
allowedSites: { [signer.address]: [ORIGIN] },
allowedSites: { [signer.address]: [HOSTNAME] },
deniedSites: {},
};
@@ -245,7 +230,6 @@ function loadBackground(options) {
// raised through action.openPopup() opens no window at all, so this is
// the only place its id appears.
const actionPopups = [];
const openPopup = jest.fn(() => Promise.resolve());
global.chrome = {
storage,
@@ -300,7 +284,7 @@ function loadBackground(options) {
// popup: no window is created, so windows.onRemoved can never
// fire for it and the port disconnect is the only close signal
// that exists.
...(opts.actionPopup ? { openPopup } : {}),
...(opts.actionPopup ? { openPopup: () => Promise.resolve() } : {}),
},
};
@@ -347,7 +331,7 @@ function loadBackground(options) {
// The same for a message-signing approval, which pins the signing address
// at approval time in exactly the same way.
function requestSign(from, origin) {
function requestSign(from) {
let rpcResult = null;
messageListener(
{
@@ -355,7 +339,7 @@ function loadBackground(options) {
method: "personal_sign",
params: [MESSAGE, from || signer.address],
},
{ origin: origin || ORIGIN },
{ origin: ORIGIN },
(r) => {
rpcResult = r;
},
@@ -369,24 +353,6 @@ function loadBackground(options) {
};
}
// The same through eth_signTypedData_v4, whose params name the address
// first and the typed data second.
function requestTypedData() {
let rpcResult = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_signTypedData_v4",
params: [signer.address, TYPED_DATA],
},
{ origin: ORIGIN },
(r) => {
rpcResult = r;
},
);
return { result: () => rpcResult };
}
// A dApp asking to connect. The origin defaults to one the persisted
// state has never allowed, so the request really does raise a prompt
// instead of being answered from allowedSites.
@@ -461,15 +427,12 @@ function loadBackground(options) {
send,
requestTx,
requestSign,
requestTypedData,
requestSite,
connectApproval,
closeWindow,
broadcastTransaction,
created,
removed,
actionPopups,
openPopup,
storage,
// The user switching account in the toolbar popup, as the background
// sees it: the persisted active address changes underneath a pending
@@ -859,238 +822,6 @@ describe("one transaction approval at a time", () => {
});
});
// A page that asks again before the user has answered its last connection or
// signature request is refused, instead of opening one more window per call.
describe("one connection and one signature approval per site at a time", () => {
const PENDING_REFUSAL = {
error: {
code: -32002,
message: expect.stringMatching(/already waiting for your answer/),
},
};
test("a loop of eth_requestAccounts opens one approval and refuses the rest", async () => {
const bg = loadBackground();
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
await settle();
expect(bg.created).toHaveLength(1);
expect(requests[0].result()).toBeNull();
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
// Once the user has answered, the site may ask again.
bg.closeWindow(1);
await settle();
expect(requests[0].result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
const again = bg.requestSite();
await settle();
expect(again.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
test("a loop of personal_sign opens one approval and refuses the rest", async () => {
const bg = loadBackground();
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestSign());
await settle();
expect(bg.created).toHaveLength(1);
expect(requests[0].result()).toBeNull();
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
});
test("a loop of eth_signTypedData_v4 opens one approval and refuses the rest", async () => {
const bg = loadBackground();
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestTypedData());
await settle();
expect(bg.created).toHaveLength(1);
expect(requests[0].result()).toBeNull();
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
});
test("a pending personal_sign also refuses eth_signTypedData_v4", async () => {
const bg = loadBackground();
bg.requestSign();
const typed = bg.requestTypedData();
await settle();
expect(typed.result()).toEqual(PENDING_REFUSAL);
expect(bg.created).toHaveLength(1);
});
test("in the toolbar popup, a loop of eth_requestAccounts opens it once", async () => {
const bg = loadBackground({ actionPopup: true });
const requests = [];
for (let i = 0; i < 5; i++) requests.push(bg.requestSite());
await settle();
expect(bg.openPopup).toHaveBeenCalledTimes(1);
for (const extra of requests.slice(1)) {
expect(extra.result()).toEqual(PENDING_REFUSAL);
}
});
// A toolbar popup that closes before it connects tells the background
// nothing, so its prompt stays pending. Once the toolbar popup has been set
// to open something else, nothing shows that prompt, and the site asking
// again must show it again rather than be refused for good.
test("a toolbar prompt nothing shows any more is shown again when the site asks again", async () => {
const bg = loadBackground({ actionPopup: true });
const first = bg.requestSite();
await settle();
const id = first.id();
// Its popup closed without connecting. Another site's prompt takes the
// toolbar popup and is answered, which sets it back to the wallet.
const other = bg.requestSite(UNCONNECTED_ORIGIN);
await settle();
const otherPort = bg.connectApproval(other.id());
otherPort.decide(false, false);
otherPort.disconnect();
await settle();
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(3);
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html?approval=" + id,
);
// The user answers it, and the first request gets that answer.
bg.connectApproval(id).decide(true, false);
await settle();
expect(first.result()).toEqual({ result: [signer.address] });
});
// The user rejects a signature request from another site, which is
// connected already. Answering any approval sets the toolbar popup back to
// the wallet.
async function rejectSignatureFromAnotherSite(bg) {
const sign = bg.requestSign(undefined, ORIGIN);
await settle();
bg.send(
{
type: "AUTISTMASK_SIGN_RESPONSE",
id: sign.id(),
approved: false,
},
{ url: bg.fromPopup.url },
);
await settle();
expect(sign.result()).toEqual({
error: { code: 4001, message: "User rejected the request." },
});
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
}
test("a toolbar prompt is shown again after another site's signature request is answered", async () => {
const bg = loadBackground({ actionPopup: true });
const first = bg.requestSite();
await settle();
const id = first.id();
// Its popup closed without connecting.
await rejectSignatureFromAnotherSite(bg);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(2);
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html?approval=" + id,
);
});
test("a prompt in a window is not opened again when the site asks again", async () => {
const bg = loadBackground({ actionPopup: true });
// The browser will not open the toolbar popup, so the prompt goes to a
// window of its own.
bg.openPopup.mockImplementation(() =>
Promise.reject(new Error("no toolbar popup")),
);
bg.requestSite();
await settle();
expect(bg.created).toHaveLength(1);
await rejectSignatureFromAnotherSite(bg);
expect(bg.created).toHaveLength(2);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(1);
expect(bg.created).toHaveLength(2);
});
test("a prompt in a connected toolbar popup is not opened again when the site asks again", async () => {
const bg = loadBackground({ actionPopup: true });
const first = bg.requestSite();
await settle();
// The popup is open and showing the prompt.
bg.connectApproval(first.id());
await rejectSignatureFromAnotherSite(bg);
const repeat = bg.requestSite();
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(bg.openPopup).toHaveBeenCalledTimes(1);
expect(bg.actionPopups[bg.actionPopups.length - 1]).toBe(
"src/popup/index.html",
);
});
test("another site's connection request is not held up", async () => {
const bg = loadBackground();
bg.requestSite();
const repeat = bg.requestSite();
const other = bg.requestSite(UNCONNECTED_ORIGIN);
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(other.result()).toBeNull();
expect(bg.created).toHaveLength(2);
});
test("another site's signature request is not held up", async () => {
const bg = loadBackground();
// Connect a second site, so that it may ask for a signature at all.
const connecting = bg.requestSite();
await settle();
const port = bg.connectApproval(connecting.id());
port.decide(true, false);
port.disconnect();
await settle();
expect(connecting.result()).toEqual({ result: [signer.address] });
bg.requestSign();
const repeat = bg.requestSign();
const other = bg.requestSign(signer.address, FRESH_ORIGIN);
await settle();
expect(repeat.result()).toEqual(PENDING_REFUSAL);
expect(other.result()).toBeNull();
expect(bg.created).toHaveLength(3);
});
});
// A nonce collision found before the transaction reaches the network is the
// one send failure the wallet can speak about with certainty. The user is told
// it did not go out and to send it again, rather than being warned it might
@@ -2462,54 +2193,12 @@ 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", () => {
// The host of FRESH_ORIGIN on another port, which makes it another site.
// FRESH_ORIGIN on another port, so its hostname is FRESH_ORIGIN's.
const FRESH_OTHER_PORT = "https://fresh.example:8443";
// A site list's container. Its [x] buttons, data attributes and all, are
@@ -2537,9 +2226,9 @@ describe("removing a site in Settings disconnects it", () => {
return list;
}
// The origins a site list shows.
// The hostnames a site list shows.
function listed(list) {
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/g)].map(
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
(match) => match[1],
);
}
@@ -2570,10 +2259,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, origin) => {
expect(listed(list)).toContain(origin);
remove: async (list, hostname) => {
expect(listed(list)).toContain(hostname);
const button = list.buttons.find(
(b) => b.dataset.origin === origin,
(b) => b.dataset.hostname === hostname,
);
await button.click();
await settle();
@@ -2585,15 +2274,14 @@ describe("removing a site in Settings disconnects it", () => {
delete global.document;
});
test("Settings lists each site by its origin", async () => {
test("Settings lists a site connected without Remember", 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_ORIGIN]);
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
expect(listed(settings.connected)).toEqual(["fresh.example"]);
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
});
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
@@ -2605,7 +2293,6 @@ 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 });
@@ -2614,7 +2301,7 @@ describe("removing a site in Settings disconnects it", () => {
};
const settings = await openSettings(bg);
await settings.remove(settings.connected, FRESH_ORIGIN);
await settings.remove(settings.connected, "fresh.example");
expect(await siteAccounts(bg)).toEqual({ result: [] });
expect(sentToTabs).toEqual([
@@ -2634,45 +2321,20 @@ describe("removing a site in Settings disconnects it", () => {
});
});
// One origin can hold both kinds of connection under two addresses:
// remembered for one, allowed without Remember for the other.
// The same hostname can hold both kinds of connection: one origin allowed
// without Remember, then another, on a different port, allowed with it.
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_OTHER_PORT);
await settings.remove(settings.allowed, "fresh.example");
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
result: [],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
result: [signer.address],
});
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
});
test("a page can neither remove a site nor list the connected ones", async () => {
@@ -2681,7 +2343,7 @@ describe("removing a site in Settings disconnects it", () => {
const page = { url: FRESH_ORIGIN + "/index.html" };
const remove = bg.send(
{ type: "AUTISTMASK_REMOVE_SITE", origin: FRESH_ORIGIN },
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
page,
);
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
+2 -1
View File
@@ -33,6 +33,7 @@ const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const MAINNET = networkById("mainnet");
@@ -80,7 +81,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
lastBalanceRefresh: 0,
+2 -1
View File
@@ -19,6 +19,7 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
@@ -85,7 +86,7 @@ function loadBackground() {
tokenHolderCache: {},
fraudContracts: [],
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
};
const storage = makeStorageStub({ autistmask: persisted });
+2 -1
View File
@@ -17,6 +17,7 @@ const { networkById } = require("../src/shared/networks");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const UNKNOWN_ORIGIN = "https://stranger.example";
const MAINNET = networkById("mainnet");
@@ -40,7 +41,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: networkById(networkId).defaultRpcUrl,
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
+3 -2
View File
@@ -21,6 +21,7 @@ const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const MAINNET = networkById("mainnet");
const SEPOLIA = networkById("sepolia");
@@ -49,7 +50,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: CUSTOM_RPC,
blockscoutUrl: CUSTOM_BLOCKSCOUT,
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
theme: "dark",
@@ -167,7 +168,7 @@ describe("a chain switch on a worker that never loaded state", () => {
expect(after.wallets).toEqual(walletFixture());
expect(after.hasWallet).toBe(true);
expect(after.activeAddress).toBe(ADDRESS);
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
expect(after.trackedTokens).toEqual([
{ address: TOKEN, symbol: "DAI", decimals: 18 },
]);
+2 -1
View File
@@ -29,6 +29,7 @@ const signer = new Wallet(SIGNER_KEY);
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const CONNECTED_ORIGIN = "https://dapp.example";
const CONNECTED_HOSTNAME = "dapp.example";
const EXT_URL = "chrome-extension://autistmask/";
const SEPOLIA = networkById("sepolia");
@@ -66,7 +67,7 @@ function storedProfile(networkId) {
networkId,
rpcUrl: net.defaultRpcUrl,
blockscoutUrl: net.defaultBlockscoutUrl,
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
deniedSites: {},
trackedTokens: [],
};
-315
View File
@@ -1,315 +0,0 @@
// The recipient line of a contract creation
// (https://git.eeqj.de/sneak/AutistMask/issues/250).
//
// A transaction with no `to` creates a contract. The approval screen, the
// wait, success and error screens, the transaction detail view and the
// transaction history rows each say so in a sentence, where they used to show
// a blank line (an empty address, with a colour dot whose colour was
// `undefined`) or, on the approval screen, "(contract creation)". A
// transaction with a real `to` still shows that address.
//
// Driven against a minimal DOM stub in the shape
// tests/typedDataPermit.test.js uses.
jest.mock("../src/shared/log", () => ({
log: {
debugf: () => {},
infof: () => {},
warnf: () => {},
errorf: () => {},
},
// The transaction detail view fetches on-chain details after drawing; an
// answer that is not ok leaves the drawn lines as they are.
debugFetch: async () => ({ ok: false }),
setRuntimeDebug: () => {},
isDebug: () => false,
}));
// The wait screen polls for a receipt; this one never arrives.
jest.mock("../src/shared/balances", () => ({
getProvider: () => ({ getTransactionReceipt: () => new Promise(() => {}) }),
refreshBalances: () => {},
}));
// The history lists ask the explorer for their transactions and resolve ENS
// names for them; here the explorer answers with mockHistory and no name
// resolves.
let mockHistory = [];
jest.mock("../src/shared/transactions", () => ({
...jest.requireActual("../src/shared/transactions"),
fetchRecentTransactions: async () => mockHistory,
}));
jest.mock("../src/shared/ens", () => ({
...jest.requireActual("../src/shared/ens"),
resolveEnsNames: async () => new Map(),
}));
globalThis.chrome = {
storage: { local: { get: async () => ({}), set: async () => {} } },
};
const { state } = require("../src/shared/state");
const approval = require("../src/popup/views/approval");
const txStatus = require("../src/popup/views/txStatus");
const transactionDetail = require("../src/popup/views/transactionDetail");
const home = require("../src/popup/views/home");
const addressDetail = require("../src/popup/views/addressDetail");
const addressToken = require("../src/popup/views/addressToken");
const SENTENCE =
"This transaction creates a new contract. It has no recipient.";
const FROM = "0x0000000000000000000000000000000000000a11";
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const TX_HASH =
"0x85215772ed26ea8b39c2b3b18779030487efbe0b5fd7e882592b2f62b837be84";
// Init code for a contract creation's data.
const INIT_CODE = "0x600160005500";
function makeElement(id) {
const classes = new Set();
const el = {
id,
textContent: "",
value: "",
innerHTML: "",
disabled: false,
style: {},
dataset: {},
classList: {
add: (...names) => names.forEach((n) => classes.add(n)),
remove: (...names) => names.forEach((n) => classes.delete(n)),
contains: (n) => classes.has(n),
toggle: (n, force) => {
const on = force === undefined ? !classes.has(n) : force;
if (on) classes.add(n);
else classes.delete(n);
return on;
},
},
addEventListener: () => {},
querySelectorAll: () => [],
appendChild: () => {},
};
// Views reach for .parentElement to hide whole sections.
Object.defineProperty(el, "parentElement", {
get: () => node(id + "-parent"),
});
return el;
}
function makeDocument() {
const els = new Map();
return {
getElementById(id) {
// The debug banner is created on demand by helpers.js; absent
// is the state a non-debug, non-testnet popup is in.
if (id === "debug-banner") return null;
if (!els.has(id)) els.set(id, makeElement(id));
return els.get(id);
},
createElement: () => makeElement("created"),
body: { prepend: () => {} },
};
}
function node(id) {
return globalThis.document.getElementById(id);
}
// The line a transaction with a real `to` shows: that address, and nothing
// left over from an empty one.
function expectAddressLine(html) {
expect(html).toContain(RECIPIENT);
expect(html).not.toContain(SENTENCE);
expect(html).not.toContain("undefined");
}
beforeEach(() => {
globalThis.document = makeDocument();
globalThis.window = { location: { search: "" } };
state.wallets = [];
state.trackedTokens = [];
state.viewData = {};
state.viewStack = [];
state.currentView = null;
txStatus.init({ doRefreshAndRender: () => {} });
});
afterEach(() => {
txStatus.endWait();
});
// Open the transaction approval screen the way the popup does: the background
// hands over the populated transaction and show() draws it.
async function openTxApproval(to, data) {
globalThis.chrome.runtime = {
connect: () => ({ postMessage: () => {} }),
sendMessage: (msg, reply) => {
if (!reply) return;
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "tx",
origin: "https://dapp.example",
isPhishingDomain: false,
approvedFrom: FROM,
approvedTx: {
type: 2,
from: FROM,
chainId: "0x1",
nonce: "0x7",
gasLimit: "0x5208",
maxPriorityFeePerGas: "0x3b9aca00",
maxFeePerGas: "0x77359400",
to,
value: "0x0",
data,
accessList: [],
},
});
},
};
approval.init({});
await approval.show(1);
}
describe("the transaction approval screen", () => {
test("a contract creation says so instead of naming a contract", async () => {
await openTxApproval(null, INIT_CODE);
expect(node("approve-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address", async () => {
await openTxApproval(RECIPIENT, "0x");
expectAddressLine(node("approve-tx-to").innerHTML);
});
});
// approval.js carries a contract creation to these screens with `to` as "".
describe("the wait, success and error screens", () => {
const creation = {
to: "",
amount: "0.0000",
token: "ETH",
tokenSymbol: null,
};
const transfer = { ...creation, to: RECIPIENT };
test("a contract creation says so on the wait screen", () => {
txStatus.showWait(creation, TX_HASH);
expect(node("wait-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address on the wait screen", () => {
txStatus.showWait(transfer, TX_HASH);
expectAddressLine(node("wait-tx-to").innerHTML);
});
test("a contract creation says so on the success and error screens", () => {
state.viewData = {
amount: "0.0000",
symbol: "ETH",
to: "",
hash: TX_HASH,
blockNumber: 1,
};
txStatus.renderSuccess();
expect(node("success-tx-to").innerHTML).toBe(SENTENCE);
txStatus.showError(creation, TX_HASH, "The transaction failed.");
expect(node("error-tx-to").innerHTML).toBe(SENTENCE);
});
test("a transaction with a recipient shows its address on the success and error screens", () => {
state.viewData = {
amount: "0.0050",
symbol: "ETH",
to: RECIPIENT,
hash: TX_HASH,
blockNumber: 1,
};
txStatus.renderSuccess();
expectAddressLine(node("success-tx-to").innerHTML);
txStatus.showError(transfer, TX_HASH, "The transaction failed.");
expectAddressLine(node("error-tx-to").innerHTML);
});
});
// A transaction FROM sent, as the history lists hold it. The explorer reports a
// contract creation with no `to`, which src/shared/transactions.js turns into
// `to: ""`.
function historyTx(to) {
return {
hash: TX_HASH,
from: FROM,
to,
value: "0.0000",
exactValue: "0.0",
rawAmount: "0",
rawUnit: "wei",
symbol: "ETH",
timestamp: 1790000000,
isError: false,
directionLabel: "Sent",
direction: "sent",
contractAddress: null,
};
}
// The detail view is opened with the transaction a history row holds.
describe("the transaction detail view", () => {
test("a contract creation says so", () => {
transactionDetail.show(historyTx(""));
expect(node("tx-detail-to").innerHTML).toBe(SENTENCE);
expect(node("tx-detail-type").textContent).toBe("Contract Creation");
});
test("a transaction with a recipient shows its address", () => {
transactionDetail.show(historyTx(RECIPIENT));
expectAddressLine(node("tx-detail-to").innerHTML);
});
});
// The same rows are drawn on Home, AddressDetail and AddressToken (for ETH).
describe.each([
["Home", "home-tx-list", () => home.render({})],
["AddressDetail", "tx-list", () => addressDetail.show()],
["AddressToken", "address-token-tx-list", () => addressToken.show()],
])("the transaction history on %s", (_name, listId, open) => {
async function rowsFor(tx) {
mockHistory = [tx];
open();
// The list is drawn once the history has been fetched.
await new Promise((resolve) => setTimeout(resolve, 0));
return node(listId).innerHTML;
}
beforeEach(() => {
state.wallets = [
{
name: "Main",
type: "key",
addresses: [{ address: FROM, balance: "0.0000" }],
},
];
state.selectedWallet = 0;
state.selectedAddress = 0;
state.selectedToken = "ETH";
});
test("a contract creation's row says so, with no colour dot and no address line", async () => {
const html = await rowsFor(historyTx(""));
expect(html).toContain(SENTENCE);
expect(html).not.toContain("background:");
expect(html).not.toContain("am-address");
expect(html).not.toContain("undefined");
});
test("a transaction with a recipient shows its colour dot and address", async () => {
const html = await rowsFor(historyTx(RECIPIENT));
expectAddressLine(html);
expect(html).toContain("background:#");
expect(html).toContain(`<div class="am-address">${RECIPIENT}</div>`);
});
});
-53
View File
@@ -1,53 +0,0 @@
// What debugFetch writes to the console in debug mode.
//
// RPC providers put the API key in the URL's path or query string, and the
// debug log used to print the whole URL and request body, so turning debug
// mode on wrote the key to the console
// (https://git.eeqj.de/sneak/AutistMask/issues/410). The log now names the
// HTTP method, the URL's origin and the JSON-RPC method, and nothing else of
// the request.
const { debugFetch, urlOrigin, setRuntimeDebug } = require("../src/shared/log");
const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
setRuntimeDebug(false);
jest.restoreAllMocks();
});
test("logs the origin and JSON-RPC method, not the key in the URL", async () => {
setRuntimeDebug(true);
const consoleLog = jest.spyOn(console, "log").mockImplementation(() => {});
globalThis.fetch = jest.fn(async () => ({ status: 200 }));
await debugFetch(
"https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "eth_chainId",
params: [],
}),
},
);
const logged = consoleLog.mock.calls.flat().join(" ");
expect(logged).not.toContain("PATHKEY123");
expect(logged).not.toContain("QUERYTOKEN456");
expect(logged).toContain("https://rpc.example.invalid");
expect(logged).toContain("eth_chainId");
});
test("the origin leaves out a user name and password in the URL", () => {
expect(
urlOrigin("https://user:SECRETPASS@rpc.example.invalid/v3/KEY"),
).toBe("https://rpc.example.invalid");
expect(urlOrigin("wss://user:SECRETPASS@rpc.example.invalid:8546/")).toBe(
"wss://rpc.example.invalid:8546",
);
});
+5 -11
View File
@@ -140,14 +140,8 @@ function load() {
state.selectedWallet = 0;
state.selectedAddress = 0;
state.activeAddress = A0;
state.allowedSites = {
[A0]: ["https://a.example"],
[B0]: ["https://b.example"],
};
state.deniedSites = {
[B0]: ["https://c.example"],
[C0]: ["https://d.example"],
};
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
state.viewStack = ["main", "settings"];
state.currentView = "settings";
@@ -394,8 +388,8 @@ describe("deleting without the password", () => {
await click("btn-delete-wallet-lost-confirm");
const saved = (await storage.get("autistmask")).autistmask;
expect(saved.allowedSites).toEqual({ [A0]: ["https://a.example"] });
expect(saved.deniedSites).toEqual({ [C0]: ["https://d.example"] });
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
});
// The route shares finishDelete() with the password route, so the
@@ -443,7 +437,7 @@ describe("deleting without the password", () => {
test("deleting the last wallet lands on Welcome with nothing left", async () => {
const { deleteWallet, state, storage } = load();
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
state.allowedSites = { [A0]: ["https://a.example"] };
state.allowedSites = { [A0]: ["a.example"] };
state.deniedSites = {};
await openLostPassword(deleteWallet, 0);
+10 -9
View File
@@ -438,10 +438,11 @@ step(
await d.switchToWindow(popup);
await d.waitVisible("#view-approve-site");
const origin = await d.text("#approve-origin");
const hostname = await d.text("#approve-hostname");
assert(
origin === env.server.origin,
"the site prompt names the wrong origin: " + JSON.stringify(origin),
hostname === "127.0.0.1",
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
);
const shown = await d.text("#approve-address");
assert(
@@ -493,16 +494,16 @@ step(
const screen = await d.execute(
`return {
origin: document.getElementById("approve-sign-origin").textContent,
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
};`,
);
assert(
screen.origin === env.server.origin,
screen.hostname === "127.0.0.1",
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.origin),
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Personal message",
@@ -570,7 +571,7 @@ step(
const screen = await d.execute(
`return {
origin: document.getElementById("approve-tx-origin").textContent,
hostname: document.getElementById("approve-tx-hostname").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
@@ -581,9 +582,9 @@ step(
};`,
);
assert(
screen.origin === env.server.origin,
screen.hostname === "127.0.0.1",
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.origin),
JSON.stringify(screen.hostname),
);
assert(
screen.from.toLowerCase().includes(env.address.toLowerCase()),
+19 -91
View File
@@ -35,7 +35,6 @@ const {
const {
DAPP_ORIGIN,
DAPP_URL,
PHISHING_DAPP_ORIGIN,
PHISHING_DAPP_URL,
FEE_ESTIMATE_WEI,
FEE_RESERVE_WEI,
@@ -1450,81 +1449,6 @@ test("an over-long flash message keeps to one line (#252)", async (env) => {
}
});
// --------------------------------------- password error containers (#297)
// Every screen that asks for a password reserves room for one line of error.
// The two on the dApp approval screens also have a border and padding, which
// that reserved height has to cover too.
const PASSWORD_ERROR_CONTAINERS = [
"approve-tx-error",
"approve-sign-error",
"export-privkey-flash",
"show-phrase-flash",
"delete-wallet-flash",
"confirm-tx-password-error",
];
// Shows only the screen holding the container, then measures the container
// and the element below it empty and again filled the way showError() in
// src/popup/views/helpers.js fills it. Runs in the page.
function measurePasswordError(id) {
const container = document.getElementById(id);
const screen = container.closest(".view");
for (const view of document.querySelectorAll(".view")) {
view.classList.toggle("hidden", view !== screen);
}
const below = container.nextElementSibling;
const measure = () => ({
height: container.getBoundingClientRect().height,
belowTop: below.getBoundingClientRect().top + window.scrollY,
belowHeight: below.getBoundingClientRect().height,
});
const empty = measure();
container.textContent = "Please enter your password.";
container.style.visibility = "visible";
const filled = measure();
container.textContent = "";
container.style.visibility = "hidden";
return { empty, filled };
}
test("a password error moves nothing on any screen (#297)", async (env) => {
const page = await openPopup(env.ctx, env.popupUrl);
try {
await page.setViewportSize(POPUP_VIEWPORT);
for (const id of PASSWORD_ERROR_CONTAINERS) {
const { empty, filled } = await page.evaluate(
measurePasswordError,
id,
);
assert(
empty.belowHeight > 0,
"nothing is shown below #" + id + ", so nothing was measured",
);
assert(
filled.height === empty.height,
"#" +
id +
" is " +
empty.height +
"px empty and " +
filled.height +
"px with an error",
);
assert(
filled.belowTop === empty.belowTop,
"the element below #" +
id +
" moved " +
(filled.belowTop - empty.belowTop) +
"px when the error appeared",
);
}
} finally {
await page.close();
}
});
// --------------------------------------------- confirmation screen (#238)
//
// The screen that decides what gets signed. The arithmetic underneath it
@@ -2472,6 +2396,8 @@ test("a token whose symbol() returns markup renders as text (#307)", async (env)
// dApp, with real funds, against a real network. The RPC is stubbed
// throughout. That pass stays on the human list before 1.0.0.
const DAPP_HOSTNAME = new URL(DAPP_URL).hostname;
// The personal_sign payload. Sent as hex, which is what dApps send and what
// the popup requires — it calls getBytes() on the message — and displayed on
// the approval screen as the decoded text, which is what the user is agreeing
@@ -3015,10 +2941,11 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
try {
await visible(popup, "#view-approve-site");
const origin = await popup.locator("#approve-origin").innerText();
const hostname = await popup.locator("#approve-hostname").innerText();
assert(
origin === DAPP_ORIGIN,
"the site prompt names the wrong origin: " + JSON.stringify(origin),
hostname === DAPP_HOSTNAME,
"the site prompt names the wrong origin: " +
JSON.stringify(hostname),
);
// The control for the phishing test below: this origin is not on the
@@ -3099,6 +3026,7 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
// check and the real approval screen. Nothing about the list is stubbed —
// there is nothing left to stub, since the extension no longer fetches it.
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
const hostname = new URL(PHISHING_DAPP_URL).hostname;
try {
await reserveApprovalTab(env);
await startRequest(
@@ -3111,15 +3039,15 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
try {
await visible(popup, "#view-approve-site");
const shown = await popup.locator("#approve-origin").innerText();
const shown = await popup.locator("#approve-hostname").innerText();
assert(
shown === PHISHING_DAPP_ORIGIN,
shown === hostname,
"the site prompt names the wrong origin: " +
JSON.stringify(shown),
);
await visible(popup, "#approve-site-phishing-warning");
console.log("# phishing warning shown for " + PHISHING_DAPP_ORIGIN);
console.log("# phishing warning shown for " + hostname);
// Not remembered: a remembered decision for this origin would
// outlive the test.
@@ -3149,15 +3077,15 @@ test("personal_sign signs, and the signature recovers to the address (#183)", as
const boundary = await watchApprovalBoundary(popup, env);
const screen = await popup.evaluate(() => ({
origin: document.getElementById("approve-sign-origin").textContent,
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").textContent,
from: document.getElementById("approve-sign-from").textContent,
}));
assert(
screen.origin === DAPP_ORIGIN,
screen.hostname === DAPP_HOSTNAME,
"the sign prompt names the wrong origin: " +
JSON.stringify(screen.origin),
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Personal message",
@@ -3242,15 +3170,15 @@ test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env
const boundary = await watchApprovalBoundary(popup, env);
const screen = await popup.evaluate(() => ({
origin: document.getElementById("approve-sign-origin").textContent,
hostname: document.getElementById("approve-sign-hostname").textContent,
type: document.getElementById("approve-sign-type").textContent,
message: document.getElementById("approve-sign-message").innerText,
from: document.getElementById("approve-sign-from").textContent,
}));
assert(
screen.origin === DAPP_ORIGIN,
screen.hostname === DAPP_HOSTNAME,
"the typed data prompt names the wrong origin: " +
JSON.stringify(screen.origin),
JSON.stringify(screen.hostname),
);
assert(
screen.type === "Typed data (EIP-712)",
@@ -3348,7 +3276,7 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
const boundary = await watchApprovalBoundary(popup, env);
const screen = await popup.evaluate(() => ({
origin: document.getElementById("approve-tx-origin").textContent,
hostname: document.getElementById("approve-tx-hostname").textContent,
from: document.getElementById("approve-tx-from").textContent,
to: document.getElementById("approve-tx-to").textContent,
value: document.getElementById("approve-tx-value").textContent,
@@ -3358,9 +3286,9 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
.classList.contains("hidden"),
}));
assert(
screen.origin === DAPP_ORIGIN,
screen.hostname === DAPP_HOSTNAME,
"the transaction prompt names the wrong origin: " +
JSON.stringify(screen.origin),
JSON.stringify(screen.hostname),
);
assert(
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
+12 -23
View File
@@ -59,32 +59,24 @@ describe("the floor under allowedSites and deniedSites", () => {
}
});
test(`an ${field} entry whose value is not an origin list is dropped`, () => {
for (const bad of [
"https://dapp.example",
42,
null,
{ a: 1 },
true,
]) {
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
expect(
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
).toEqual({});
}
});
test(`an origin that is not text is dropped from an ${field} entry`, () => {
test(`a hostname that is not text is dropped from an ${field} entry`, () => {
expect(
normalizePersisted({
[field]: {
[ADDRESS]: [42, null, "https://dapp.example", {}],
},
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
})[field],
).toEqual({ [ADDRESS]: ["https://dapp.example"] });
).toEqual({ [ADDRESS]: ["dapp.example"] });
});
test(`a real ${field} map survives, copied not shared`, () => {
const saved = { [field]: { [ADDRESS]: ["https://dapp.example"] } };
const saved = { [field]: { [ADDRESS]: ["dapp.example"] } };
const out = normalizePersisted(saved);
@@ -95,13 +87,10 @@ describe("the floor under allowedSites and deniedSites", () => {
test(`a good ${field} entry beside a malformed one survives`, () => {
const out = normalizePersisted({
[field]: {
[ADDRESS]: ["https://dapp.example"],
[TOKEN_ADDRESS]: 42,
},
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
});
expect(out[field]).toEqual({ [ADDRESS]: ["https://dapp.example"] });
expect(out[field]).toEqual({ [ADDRESS]: ["dapp.example"] });
});
test(`a stored own "__proto__" key in ${field} is dropped`, () => {
@@ -111,7 +100,7 @@ describe("the floor under allowedSites and deniedSites", () => {
// saveState()'s merge hands to the prototype setter on the next
// write.
const saved = JSON.parse(
'{"' + field + '":{"__proto__":["https://evil.invalid"]}}',
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
);
const out = normalizePersisted(saved);
@@ -208,7 +197,7 @@ describe("a malformed allowedSites entry", () => {
const MALFORMED = [
{ name: "a string", value: "notalist" },
{ name: "a number", value: 42 },
{ name: "a record", value: { origins: ["https://dapp.example"] } },
{ name: "a record", value: { hostnames: ["dapp.example"] } },
];
for (const { name, value } of MALFORMED) {
@@ -242,7 +231,7 @@ describe("a malformed allowedSites entry", () => {
const env = await bootPopup(
unversionedValidProfile({
allowedSites: {
[ADDRESS]: ["https://dapp.example"],
[ADDRESS]: ["dapp.example"],
[TOKEN_ADDRESS]: "notalist",
},
}),
@@ -250,7 +239,7 @@ describe("a malformed allowedSites entry", () => {
expect(env.pageErrors).toEqual([]);
expect(env.storage.read("autistmask").allowedSites).toEqual({
[ADDRESS]: ["https://dapp.example"],
[ADDRESS]: ["dapp.example"],
});
});
+2 -2
View File
@@ -165,7 +165,7 @@ const CONTRACT = [
[ADDRESS],
{ [ADDRESS]: 42 },
{ [ADDRESS]: [42, null, {}] },
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
JSON.parse('{"__proto__":["evil.invalid"]}'),
],
holds: siteMapHolds,
},
@@ -177,7 +177,7 @@ const CONTRACT = [
[ADDRESS],
{ [ADDRESS]: 42 },
{ [ADDRESS]: [42, null, {}] },
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
JSON.parse('{"__proto__":["evil.invalid"]}'),
],
holds: siteMapHolds,
},
-105
View File
@@ -1,105 +0,0 @@
// What reaches the console when the RPC endpoint answers with an HTTP error.
//
// RPC providers put the API key in the endpoint URL's path or query string.
// When the endpoint answers with an HTTP error (a wrong or expired key, a rate
// limit, a server error), the error ethers throws carries the full request URL
// in its message, so a line logging that message printed the key
// (https://git.eeqj.de/sneak/AutistMask/issues/410). Those lines log the
// error's short message, which names the HTTP status and not the URL.
//
// The real ethers provider runs; only its HTTP transport is replaced, by one
// that answers every request with 401 Unauthorized. Debug mode is on, so
// every log level is printed.
const { FetchRequest } = require("ethers");
const {
getProvider,
lookupTokenInfo,
refreshBalances,
} = require("../src/shared/balances");
const { getFullWarnings } = require("../src/shared/addressWarnings");
const { resolveEnsName } = require("../src/shared/ens");
const { setRuntimeDebug } = require("../src/shared/log");
const RPC_URL = "https://rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
const ADDRESS = "0x1111111111111111111111111111111111111111";
const realFetch = globalThis.fetch;
let printed;
beforeEach(() => {
setRuntimeDebug(true);
printed = [];
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
printed.push(args.map(String).join(" "));
});
}
FetchRequest.registerGetUrl(async () => ({
statusCode: 401,
statusMessage: "Unauthorized",
headers: {},
body: new Uint8Array(),
}));
// The explorer requests the balance refresh makes go nowhere.
globalThis.fetch = jest.fn(async () => {
throw new Error("tests must not perform network requests");
});
});
afterEach(() => {
FetchRequest.registerGetUrl(FetchRequest.createGetUrlFunc());
globalThis.fetch = realFetch;
setRuntimeDebug(false);
jest.restoreAllMocks();
});
// The line carrying `label` was printed and names the HTTP status, and nothing
// printed carries the key.
function expectFailureLoggedWithoutKey(label) {
const line = printed.find((text) => text.includes(label));
expect(line).toContain("401");
const all = printed.join("\n");
expect(all).not.toContain("PATHKEY123");
expect(all).not.toContain("QUERYTOKEN456");
}
test("ethers puts the URL in the error message, not in the short message", async () => {
const provider = getProvider(RPC_URL, "mainnet");
const error = await provider.getCode(ADDRESS).catch((e) => e);
expect(error.message).toContain("PATHKEY123");
expect(error.shortMessage).not.toContain("PATHKEY123");
});
test("the recipient checks before a send", async () => {
await getFullWarnings(ADDRESS, getProvider(RPC_URL, "mainnet"));
expectFailureLoggedWithoutKey("contract check failed");
expectFailureLoggedWithoutKey("tx count check failed");
});
test("the ENS reverse lookup", async () => {
expect(await resolveEnsName(ADDRESS, RPC_URL, "mainnet")).toBeNull();
expectFailureLoggedWithoutKey("ENS reverse lookup failed");
});
test("the balance refresh", async () => {
const wallets = [{ addresses: [{ address: ADDRESS }] }];
await refreshBalances(
wallets,
RPC_URL,
"https://explorer.example.invalid/api/v2",
[],
"mainnet",
);
expectFailureLoggedWithoutKey("ETH balance failed");
expectFailureLoggedWithoutKey("ENS reverse failed");
});
// The lookup's first line, at debug level, names the RPC endpoint; the check
// of everything printed covers it too.
test("the token lookup", async () => {
await expect(lookupTokenInfo(ADDRESS, RPC_URL, "mainnet")).rejects.toThrow(
"Not a valid ERC-20 token",
);
expectFailureLoggedWithoutKey("symbol() failed:");
});
-146
View File
@@ -1,146 +0,0 @@
// Which site a page's request is attributed to.
//
// The background takes a request's origin from what the browser says sent the
// message: sender.origin, or on Firefox before 126, which has no
// sender.origin, the origin of sender.url — the frame that sent it. It used to
// fall back to the tab's page and then to an origin the message itself
// carried, so a request from a frame was credited to the site embedding it,
// and a request the browser said nothing about was credited to whatever the
// page wrote (https://git.eeqj.de/sneak/AutistMask/issues/407).
//
// Every sender here lacks sender.origin, as on old Firefox. The connection
// check on eth_accounts is what shows which site a request was credited to.
const { makeStorageStub } = require("./support/storageStub");
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
// The site the persisted state has connected, and one it has never heard of.
const CONNECTED_ORIGIN = "https://dapp.example";
const STRANGER_ORIGIN = "https://stranger.example";
async function settle() {
for (let i = 0; i < 50; i++) await Promise.resolve();
}
afterEach(() => {
delete global.chrome;
});
function loadBackground() {
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(),
}));
const storage = makeStorageStub({
autistmask: {
networkId: "mainnet",
wallets: [
{
name: "Wallet 1",
type: "hd",
addresses: [
{ address: ADDRESS, balance: "0", tokenBalances: [] },
],
},
],
activeAddress: ADDRESS,
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
deniedSites: {},
},
});
let messageListener = null;
global.chrome = {
storage,
runtime: {
getURL: (path) => "chrome-extension://autistmask/" + path,
onMessage: {
addListener: (fn) => {
messageListener = fn;
},
},
onConnect: { addListener: () => {} },
lastError: null,
},
windows: { onRemoved: { addListener: () => {} } },
action: { setPopup: () => {} },
};
require("../src/background/index");
// Ask for eth_accounts. `claimedOrigin` is an origin written into the
// message, as the content script used to send.
return async function accounts(sender, claimedOrigin) {
let result = null;
messageListener(
{
type: "AUTISTMASK_RPC",
method: "eth_accounts",
params: [],
origin: claimedOrigin,
},
sender,
(r) => {
result = r;
},
);
await settle();
return result;
};
}
describe("a request is attributed to the frame that sent it", () => {
test("a stranger's frame on a connected site gets no address", async () => {
const accounts = loadBackground();
const result = await accounts({
url: STRANGER_ORIGIN + "/frame.html",
tab: { url: CONNECTED_ORIGIN + "/" },
});
expect(result).toEqual({ result: [] });
});
test("a connected site's frame on a stranger's page gets the address", async () => {
const accounts = loadBackground();
const result = await accounts({
url: CONNECTED_ORIGIN + "/frame.html",
tab: { url: STRANGER_ORIGIN + "/" },
});
expect(result).toEqual({ result: [ADDRESS] });
});
});
describe("a request the browser does not say the sender of", () => {
test("is refused, whatever the tab or the message says", async () => {
const accounts = loadBackground();
const result = await accounts(
{ tab: { url: CONNECTED_ORIGIN + "/" } },
CONNECTED_ORIGIN,
);
expect(result).toEqual({
error: {
code: 4100,
message:
"The wallet could not tell which site sent this request.",
},
});
});
});
+1 -2
View File
@@ -66,7 +66,6 @@ jest.mock("../src/shared/log", () => ({
status: 200,
json: async () => mockExplorer.items,
})),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
@@ -233,7 +232,7 @@ async function confirmSend(amount, token = "ETH") {
async function approveTxWithFeePerGas(maxFeePerGas) {
approvalDetails = {
type: "tx",
origin: "https://dapp.example",
hostname: "dapp.example",
approvedFrom: HOLDER,
approvedTx: {
to: RECIPIENT,
-148
View File
@@ -1,148 +0,0 @@
// What reaches the console when an endpoint check in Settings fails.
//
// fetch refuses a URL with a user name and password in it, or one it cannot
// parse, with an error whose message carries the whole URL: the password, and
// any API key in the path or query string. The checks behind the RPC and
// Blockscout Save buttons printed that message
// (https://git.eeqj.de/sneak/AutistMask/issues/410); they now name the
// endpoint by its origin.
//
// The real fetch runs; it throws before making any request. Debug mode is on,
// so every log level is printed.
const SECRETS = ["SECRETPASS789", "PATHKEY123", "QUERYTOKEN456"];
const RPC_WITH_PASSWORD =
"https://user:SECRETPASS789@rpc.example.invalid/v3/PATHKEY123?token=QUERYTOKEN456";
// Port 99999 is out of range, so the URL does not parse.
const RPC_UNPARSEABLE =
"https://rpc.example.invalid:99999/v3/PATHKEY123?token=QUERYTOKEN456";
const BLOCKSCOUT_WITH_PASSWORD =
"https://user:SECRETPASS789@explorer.example.invalid/PATHKEY123/api/v2";
const SAVED_RPC = "https://saved-rpc.example.invalid";
const SAVED_BLOCKSCOUT = "https://saved-explorer.example.invalid/api/v2";
let elements;
let flashes;
let printed;
let state;
// A stand-in for one DOM node: enough of an element for init() to set
// properties on it and hang listeners off it.
function fakeElement() {
return {
value: "",
checked: false,
textContent: "",
href: "",
style: {},
dataset: {},
classList: { add() {}, remove() {} },
listeners: {},
addEventListener(event, handler) {
this.listeners[event] = handler;
},
querySelectorAll: () => [],
};
}
function element(id) {
return (elements[id] ||= fakeElement());
}
function loadSettingsView() {
elements = {};
flashes = [];
jest.resetModules();
jest.doMock("../src/popup/views/helpers", () => ({
$: element,
showView: () => {},
updateDebugBanner: () => {},
showFlash: (msg) => flashes.push(msg),
escapeHtml: (s) => s,
flashCopyFeedback: () => {},
goBack: () => {},
pushCurrentView: () => {},
onViewLeave: () => {},
VIEWS: [],
}));
state = require("../src/shared/state").state;
state.rpcUrl = SAVED_RPC;
state.blockscoutUrl = SAVED_BLOCKSCOUT;
require("../src/shared/log").setRuntimeDebug(true);
require("../src/popup/views/settings").init({});
}
async function save(fieldId, buttonId, typed) {
element(fieldId).value = typed;
await element(buttonId).listeners.click();
}
// The check failed, nothing was saved, and nothing printed carries the
// password or the key.
function expectFailedWithoutSecrets(label) {
expect(flashes).toContain("Could not reach endpoint.");
expect(state.rpcUrl).toBe(SAVED_RPC);
expect(state.blockscoutUrl).toBe(SAVED_BLOCKSCOUT);
const line = printed.find((text) => text.includes(label));
expect(line).toBeDefined();
const all = printed.join("\n");
for (const secret of SECRETS) {
expect(all).not.toContain(secret);
}
return line;
}
beforeEach(() => {
printed = [];
for (const method of ["log", "warn", "error"]) {
jest.spyOn(console, method).mockImplementation((...args) => {
printed.push(args.map(String).join(" "));
});
}
globalThis.chrome = {
runtime: { sendMessage: () => {} },
storage: { local: { get: async () => ({}), set: async () => {} } },
};
});
afterEach(() => {
jest.dontMock("../src/popup/views/helpers");
delete globalThis.chrome;
jest.restoreAllMocks();
});
test("fetch puts the whole URL in the error it throws for such a URL", async () => {
for (const url of [RPC_WITH_PASSWORD, RPC_UNPARSEABLE]) {
const error = await fetch(url).catch((e) => e);
expect(error.message).toContain("PATHKEY123");
}
});
test("the RPC check of a URL with a user name and password", async () => {
loadSettingsView();
await save("settings-rpc", "btn-save-rpc", RPC_WITH_PASSWORD);
const line = expectFailedWithoutSecrets("RPC validation fetch failed");
expect(line).toContain("https://rpc.example.invalid");
});
test("the RPC check of a URL that does not parse", async () => {
loadSettingsView();
await save("settings-rpc", "btn-save-rpc", RPC_UNPARSEABLE);
expectFailedWithoutSecrets("RPC validation fetch failed");
});
test("the Blockscout check of a URL with a user name and password", async () => {
loadSettingsView();
await save(
"settings-blockscout",
"btn-save-blockscout",
BLOCKSCOUT_WITH_PASSWORD,
);
const line = expectFailedWithoutSecrets("Blockscout validation failed");
expect(line).toContain("https://explorer.example.invalid");
});
+31 -33
View File
@@ -270,32 +270,31 @@ describe("background refresh racing a wallet deleted on another page", () => {
});
});
// allowedSites/deniedSites: { [address]: [origin, ...] }. Mutated in place
// from two different contexts — rememberSiteChoice() in
// src/background/index.js pushes a newly approved origin onto
// state.allowedSites[activeAddress], and the Settings "revoke" button
// (forgetOrigin() in src/popup/views/settings.js) filters an origin out of
// state[key][addr] in place, deleting the address key entirely once its list
// is empty — the exact membership-vs-whole-field pattern that made the
// whole-field `wallets` diff unsafe, on a security-relevant field: a stale
// whole-field save here can resurrect a revoked permission or wipe a freshly
// granted one.
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
// from two different contexts — src/background/index.js:592-599 pushes a
// newly approved hostname onto state.allowedSites[activeAddress], and the
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
// hostname out of state[key][addr] in place, deleting the address key
// entirely once its list is empty — the exact membership-vs-whole-field
// pattern that made the whole-field `wallets` diff unsafe, on a
// security-relevant field: a stale whole-field save here can resurrect a
// revoked permission or wipe a freshly granted one.
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
function approveSite(pageState, address, origin) {
function approveSite(pageState, address, hostname) {
if (!pageState.allowedSites[address]) {
pageState.allowedSites[address] = [];
}
if (!pageState.allowedSites[address].includes(origin)) {
pageState.allowedSites[address].push(origin);
if (!pageState.allowedSites[address].includes(hostname)) {
pageState.allowedSites[address].push(hostname);
}
}
function revokeSite(pageState, origin) {
function revokeSite(pageState, hostname) {
for (const addr of Object.keys(pageState.allowedSites)) {
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
(o) => o !== origin,
(h) => h !== hostname,
);
if (pageState.allowedSites[addr].length === 0) {
delete pageState.allowedSites[addr];
@@ -309,7 +308,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR2]: ["https://other.example"] },
allowedSites: { [ADDR2]: ["other.example"] },
},
});
@@ -319,24 +318,24 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
await settings.state.loadState();
// A dApp approval window, opened later, approves a new site for a
// different address and saves — the real sequence in
// rememberSiteChoice(), src/background/index.js.
// different address and saves — the real sequence at
// src/background/index.js:592-599.
const approval = loadPage(storage);
await approval.state.loadState();
approveSite(approval.state.state, ADDR1, "https://dapp.example");
approveSite(approval.state.state, ADDR1, "dapp.example");
await approval.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toEqual(["https://dapp.example"]);
).toEqual(["dapp.example"]);
// Settings revokes its own, unrelated site — the real sequence in
// forgetOrigin(), src/popup/views/settings.js — and saves from state
// loaded before the dApp approval ever happened.
revokeSite(settings.state.state, "https://other.example");
// Settings revokes its own, unrelated site — the real sequence at
// src/popup/views/settings.js:55-68 — and saves from state loaded
// before the dApp approval ever happened.
revokeSite(settings.state.state, "other.example");
await settings.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR1]).toEqual(["https://dapp.example"]);
expect(persisted.allowedSites[ADDR1]).toEqual(["dapp.example"]);
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
});
});
@@ -347,7 +346,7 @@ describe("a revoked site permission against a stale page's later save", () => {
await storage.set({
autistmask: {
wallets: [W1],
allowedSites: { [ADDR1]: ["https://evil.example"] },
allowedSites: { [ADDR1]: ["evil.example"] },
},
});
@@ -355,24 +354,23 @@ describe("a revoked site permission against a stale page's later save", () => {
const stale = loadPage(storage);
await stale.state.loadState();
// Settings revokes it — forgetOrigin(), src/popup/views/settings.js —
// from a second page.
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
// second page.
const settings = loadPage(storage);
await settings.state.loadState();
revokeSite(settings.state.state, "https://evil.example");
revokeSite(settings.state.state, "evil.example");
await settings.state.saveState();
expect(
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
).toBeUndefined();
// The stale page, unaware of the revoke, approves an unrelated site
// for a different address and saves — rememberSiteChoice(),
// src/background/index.js.
approveSite(stale.state.state, ADDR2, "https://good.example");
// for a different address and saves — src/background/index.js:592-599.
approveSite(stale.state.state, ADDR2, "good.example");
await stale.state.saveState();
const persisted = (await storage.get("autistmask")).autistmask;
expect(persisted.allowedSites[ADDR2]).toEqual(["https://good.example"]);
expect(persisted.allowedSites[ADDR2]).toEqual(["good.example"]);
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
});
});
+1 -3
View File
@@ -167,9 +167,7 @@ describe("an unversioned profile that is perfectly valid", () => {
expect(stored.wallets[0].encryptedSecret).toBe("encrypted-secret-1");
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
expect(stored.activeAddress).toBe(ADDRESS);
expect(stored.allowedSites).toEqual({
[ADDRESS]: ["https://dapp.example"],
});
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
});
});
+1 -21
View File
@@ -20,26 +20,6 @@ const path = require("path");
const { makeStorageStub } = require("./storageStub");
// The four libraries the popup loads from node_modules, loaded once per test
// file and handed to every boot. jest.resetModules() in bootPopup() empties the
// module cache but keeps what jest.doMock() registered, so these registrations
// hold for every boot in the file and the libraries are not loaded again. None
// of them holds popup state; everything under src/ is still loaded fresh on
// each boot.
//
// A test's own mock of one of them: a jest.doMock() made inside the test
// replaces the registration here, as it would for any module. A top-of-file
// jest.mock() is what the require() below gets, so it is kept, but its factory
// runs once per file and every boot shares the same mock object.
const ethers = require("ethers");
const sodium = require("libsodium-wrappers-sumo");
const QRCode = require("qrcode");
const makeBlockie = require("ethereum-blockies-base64");
jest.doMock("ethers", () => ethers);
jest.doMock("libsodium-wrappers-sumo", () => sodium);
jest.doMock("qrcode", () => QRCode);
jest.doMock("ethereum-blockies-base64", () => makeBlockie);
const POPUP_HTML = fs.readFileSync(
path.join(__dirname, "..", "..", "src", "popup", "index.html"),
"utf8",
@@ -71,7 +51,7 @@ function unversionedValidProfile(extra) {
networkId: "mainnet",
rpcUrl: "https://ethereum-rpc.publicnode.com",
blockscoutUrl: "https://eth.blockscout.com/api/v2",
allowedSites: { [ADDRESS]: ["https://dapp.example"] },
allowedSites: { [ADDRESS]: ["dapp.example"] },
deniedSites: {},
trackedTokens: [],
theme: "system",
-1
View File
@@ -44,7 +44,6 @@ jest.mock("../src/shared/log", () => ({
errorf: () => {},
},
debugFetch: jest.fn(),
urlOrigin: () => "",
setRuntimeDebug: () => {},
isDebug: () => false,
}));
+1 -1
View File
@@ -471,7 +471,7 @@ async function openSignScreen(data) {
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
reply({
type: "sign",
origin: "https://dapp.example",
hostname: "dapp.example",
isPhishingDomain: false,
approvedFrom: OWNER,
signParams: request(data),
-29
View File
@@ -431,35 +431,6 @@ describe("uniswap decoder", () => {
expect(detail(result, "Min. received").value).toBe("0.0005 ETH");
});
// A step that states a Min. received figure but names no output token has
// set the output side, so UNWRAP_WETH does not make it ETH: nothing says
// the figure is counted in WETH.
test("a step with a minimum but no output token, then UNWRAP_WETH, names no token", () => {
const data = buildExecute(
solidityPacked(["uint8", "uint8"], [0x10, 0x0c]),
[
encodeV4Swap(new Uint8Array([V4_SWAP_EXACT_IN]), [
encodeV4ExactIn(
USDC_ADDR,
[], // no path: this step names no output currency
1000000000n, // 1,000 USDC
400000000000000000n, // amountOutMin
),
]),
encodeWrapEth(USER_ADDR, 0n), // UNWRAP_WETH
],
9999999999n,
);
const result = uniswap.decode(data, ROUTER_ADDR);
expect(detail(result, "Token Out").value).toBe(
"Unknown (not named in the calldata)",
);
expect(detail(result, "Min. received").value).toBe(
"400000000000000000 base units (decimals unknown)",
);
});
test("decodes V3_SWAP_EXACT_IN with known tokens", () => {
const data = buildExecute(
"0x00", // V3_SWAP_EXACT_IN
-48
View File
@@ -382,56 +382,8 @@ describe("a scale the explorer's own rows disagree about", () => {
);
});
// https://git.eeqj.de/sneak/AutistMask/issues/377. The Send screen read the
// stored balance and said "5.0000 NOVEL", the confirmation screen it leads
// to said "unknown (NOVEL)", and the fee message asked the user to go back
// and try again, which cannot supply a scale.
test("reads the same on the Send screen and the confirmation screen, and the fee message names the scale", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("18", FIVE_WETH)]);
state.selectedToken = NOVEL;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: unknown (NOVEL)");
const txInfo = await reviewSend(NOVEL, "1.5");
confirmTx.show(txInfo);
await settle();
expect(text("confirm-balance")).toBe("unknown (NOVEL)");
expect(text("confirm-fee-unknown-error")).toBe(
"The network fee could not be estimated, because this wallet" +
" does not know how many decimal places this token uses, so" +
" this transaction cannot be sent.",
);
expect(el("confirm-fee-unknown-error").style.visibility).toBe(
"visible",
);
});
test("while a fee that fails for any other reason keeps its retry", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
const txInfo = await reviewSend(NOVEL, "1.5");
const getFeeData = mockProvider.getFeeData;
mockProvider.getFeeData = async () => {
throw new Error("the node did not answer");
};
try {
confirmTx.show(txInfo);
await settle();
} finally {
mockProvider.getFeeData = getFeeData;
}
expect(text("confirm-fee-amount")).toBe("Unable to estimate");
expect(text("confirm-fee-unknown-error")).toBe(
"The network fee could not be estimated, so this transaction" +
" cannot be checked against your balance. Please go back and" +
" try again.",
);
});
test("while agreeing rows leave the scale usable", async () => {
await fetchOntoBoth([novel("6", 5000000n)], [novel("6", 5000000n)]);
state.selectedToken = NOVEL;
send.updateSendBalance();
expect(text("send-balance")).toBe("Current balance: 5.0000 NOVEL");
const txInfo = await reviewSend(NOVEL, "1.5");
expect(txInfo.tokenDecimals).toBe(6);
expect(txInfo.tokenBalance).toBe("5.0");
+11 -20
View File
@@ -28,14 +28,11 @@ function makeState(overrides = {}) {
selectedAddress: 0,
activeAddress: A0,
allowedSites: {
[A0]: ["https://a.example"],
[A1]: ["https://b.example"],
[B0]: ["https://c.example"],
},
deniedSites: {
[A1]: ["https://d.example"],
[C0]: ["https://e.example"],
[A0]: ["a.example"],
[A1]: ["b.example"],
[B0]: ["c.example"],
},
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
...overrides,
};
}
@@ -44,7 +41,7 @@ describe("removeWalletFromState", () => {
test("deleting the last wallet clears hasWallet", () => {
const state = makeState({
wallets: [wallet("A", [A0])],
allowedSites: { [A0]: ["https://a.example"] },
allowedSites: { [A0]: ["a.example"] },
deniedSites: {},
});
@@ -112,8 +109,8 @@ describe("removeWalletFromState", () => {
removeWalletFromState(state, 0);
expect(state.allowedSites).toEqual({ [B0]: ["https://c.example"] });
expect(state.deniedSites).toEqual({ [C0]: ["https://e.example"] });
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
});
});
@@ -129,14 +126,8 @@ function makeAddressState(overrides = {}) {
selectedWallet: 0,
selectedAddress: 0,
activeAddress: A0,
allowedSites: {
[A0]: ["https://a.example"],
[A1]: ["https://b.example"],
},
deniedSites: {
[A1]: ["https://d.example"],
[B0]: ["https://e.example"],
},
allowedSites: { [A0]: ["a.example"], [A1]: ["b.example"] },
deniedSites: { [A1]: ["d.example"], [B0]: ["e.example"] },
...overrides,
};
}
@@ -282,8 +273,8 @@ describe("removeAddressFromState", () => {
removeAddressFromState(state, 0, 1);
expect(state.allowedSites).toEqual({ [A0]: ["https://a.example"] });
expect(state.deniedSites).toEqual({ [B0]: ["https://e.example"] });
expect(state.allowedSites).toEqual({ [A0]: ["a.example"] });
expect(state.deniedSites).toEqual({ [B0]: ["e.example"] });
});
// The derivation counter is a high-water mark, never rewound: "+" derives