Compare commits
1
Commits
next
..
1830e7cb68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1830e7cb68 |
@@ -887,9 +887,7 @@ Truncation stays truncation: `0.99999` shows as `0.9999`, never rounded up. The
|
|||||||
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
rule still renders a genuine zero as `0.0000`. Two lines of a swap say a zero in
|
||||||
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
|
words instead: `Min. received` reads `None (no minimum guaranteed)` for a zero
|
||||||
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
|
minimum, and `Amount` reads `All available (V4 open delta)` when the amount it
|
||||||
shows is a V4 exact-in `amountIn` of zero and
|
shows is a V4 exact-in `amountIn` of zero.
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)` when it is a V2
|
|
||||||
exact-in `amountIn` of zero.
|
|
||||||
|
|
||||||
The rule and its exception live in `src/shared/amountDisplay.js` as
|
The rule and its exception live in `src/shared/amountDisplay.js` as
|
||||||
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
`truncateAmount()` and `truncateAmountNeverZero()`. Everything the approval and
|
||||||
@@ -928,10 +926,7 @@ rule: the ERC-20 `transfer`/`approve` line (`src/popup/views/approval.js`) and
|
|||||||
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
the swap's `Amount` and `Min. received` lines (`src/shared/uniswap.js`). The
|
||||||
token permission warning on the signature screen takes the same rule for its
|
token permission warning on the signature screen takes the same rule for its
|
||||||
amounts. An unbounded allowance or permit needs no scale to describe and is
|
amounts. An unbounded allowance or permit needs no scale to describe and is
|
||||||
still shown as `Unlimited`. A source's answer counts only if it is a whole
|
still shown as `Unlimited`.
|
||||||
number from 0 to 80: `decimals()` returns a `uint8`, but `formatUnits()` cannot
|
|
||||||
format more than 80 decimal places, so a token that reports 81 to 255 is shown
|
|
||||||
as one whose scale nothing knows.
|
|
||||||
|
|
||||||
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
The rule holds only if nothing invents a scale UPSTREAM of it. Those three
|
||||||
sources are read as authoritative, so a value written into one of them cannot be
|
sources are read as authoritative, so a value written into one of them cannot be
|
||||||
@@ -982,8 +977,7 @@ read:
|
|||||||
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
exact-out step, whichever step set the line, including the `WRAP_ETH` of a
|
||||||
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
swap paid in ETH and a `PERMIT2_PERMIT`. The swap spends at most that figure,
|
||||||
not necessarily all of it; the wait, success and error screens show it with
|
not necessarily all of it; the wait, success and error screens show it with
|
||||||
the same words. `Unlimited`, `All available (V4 open delta)` and
|
the same words. `Unlimited` and `All available (V4 open delta)` keep their
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)` keep their
|
|
||||||
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
wording. When a V2 exact-out step sets `Min. received`, that line shows its
|
||||||
`amountOut`, the exact amount it buys.
|
`amountOut`, the exact amount it buys.
|
||||||
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
- `All available (V4 open delta)`: the swap's `Amount` line, when the amount it
|
||||||
@@ -994,22 +988,11 @@ read:
|
|||||||
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
V2 exact-out, `WRAP_ETH` or V4 swap step. A V2 exact-out step gives its
|
||||||
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
`amountInMax`, and a V4 swap step the `amountIn` of its first readable
|
||||||
exact-in action.
|
exact-in action.
|
||||||
- `Whatever an earlier step sent to the pair (V2 already paid)`: the swap's
|
|
||||||
`Amount` line, when the amount it shows is a V2 exact-in `amountIn` of zero.
|
|
||||||
The router reads that zero as "the pair already holds the input tokens": the
|
|
||||||
step pays nothing itself and swaps whatever an earlier step sent to the pair,
|
|
||||||
so the calldata states no quantity. A V3 exact-in `amountIn` of zero has no
|
|
||||||
such meaning and is shown as a zero.
|
|
||||||
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
- `None (no minimum guaranteed)`: the swap's `Min. received` line, when the
|
||||||
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
minimum it shows is zero, whether a V2, V3 or V4 swap's minimum or a
|
||||||
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
`BALANCE_CHECK_ERC20` step's `minBalance`. Before
|
||||||
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
[#359](https://git.eeqj.de/sneak/AutistMask/issues/359), a zero `minBalance`
|
||||||
read `0.0000` when the token's scale was known. The router passes a balance
|
read `0.0000` when the token's scale was known.
|
||||||
check whenever the balance is at least `minBalance`, so a zero `minBalance`
|
|
||||||
guarantees nothing: it sets `Token Out` and `Min. received` only when the
|
|
||||||
output side holds no minimum, not even a zero one, at the point the check is
|
|
||||||
reached, and otherwise leaves the current token and figure in place. A nonzero
|
|
||||||
`minBalance` sets both lines, as a swap step does.
|
|
||||||
|
|
||||||
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
The swap's `Token In` and `Token Out` lines name a currency, not an amount; each
|
||||||
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
reads `Unknown (not named in the calldata)` when the decoder found no token for
|
||||||
@@ -1174,7 +1157,7 @@ each caught only by a reviewer re-deriving thirty fields by hand.
|
|||||||
The `allowedSites` case is why the entry check is not optional. A stored
|
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
|
`{"0x…": "notalist"}` is a well-formed object holding a malformed entry: it
|
||||||
passed the gate, rendered a completely healthy popup, and then threw inside
|
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
|
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
|
([#362](https://git.eeqj.de/sneak/AutistMask/issues/362)). A save that fails is
|
||||||
now also reported rather than swallowed: `onSaveFailure()` in
|
now also reported rather than swallowed: `onSaveFailure()` in
|
||||||
@@ -1648,13 +1631,13 @@ view would leave a wallet one click from deletion.
|
|||||||
a value carrying its unit, hex (`0x10`) or exponent (`1e3`) notation —
|
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
|
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.
|
threshold, so a number the user did not type is never stored.
|
||||||
- Allowed Sites: the origins (scheme, host and port) remembered as allowed,
|
- Allowed Sites: the hostnames remembered as allowed, under any address,
|
||||||
under any address, with remove buttons
|
with remove buttons
|
||||||
- Connected Sites: the origins of the sites allowed without "Remember my
|
- Connected Sites: the hostnames of the sites allowed without "Remember my
|
||||||
choice" that are still connected, with remove buttons. Only the background
|
choice" that are still connected, with remove buttons. Only the background
|
||||||
holds these, in memory, and Settings asks it for them with
|
holds these, in memory, and Settings asks it for them with
|
||||||
`AUTISTMASK_GET_CONNECTED_SITES`
|
`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
|
remove buttons
|
||||||
- About: project link, license, author, version, release date, and the
|
- About: project link, license, author, version, release date, and the
|
||||||
commit, which links to the commit in the repository
|
commit, which links to the commit in the repository
|
||||||
@@ -1668,11 +1651,10 @@ view would leave a wallet one click from deletion.
|
|||||||
- Tap wallet name → inline rename field (no screen change)
|
- Tap wallet name → inline rename field (no screen change)
|
||||||
- `[x]` on a tracked token → removes it in place (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:
|
- `[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
|
`AUTISTMASK_REMOVE_SITE` has the background end every connection approved
|
||||||
without "Remember" from that origin, under any address, and send
|
without "Remember" from an origin with that hostname, under any address,
|
||||||
`accountsChanged` with an empty list to the open tabs of that origin. The
|
and send `accountsChanged` with an empty list to the site's open tabs.
|
||||||
same host under another scheme or port is another site and is left alone.
|
|
||||||
Only the extension's own pages may send either message
|
Only the extension's own pages may send either message
|
||||||
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
- `[x]` on a denied site → forgets the refusal, in place; it connects
|
||||||
nothing and tells the background nothing
|
nothing and tells the background nothing
|
||||||
@@ -1856,22 +1838,14 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
- **When**: A website requests wallet access via `eth_requestAccounts` or
|
||||||
`wallet_requestPermissions` and is on neither the allowed nor the denied list.
|
`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
|
The background script prefers the toolbar popup (`action.openPopup()`) and
|
||||||
connection allowed without "Remember": a choice for `https://dapp.example`
|
falls back to a separate popup window (`src/background/index.js`,
|
||||||
says nothing about `http://dapp.example` or another port of that host. The
|
`requestApproval()`).
|
||||||
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.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Connection Request" heading
|
- "Connection Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants to connect to your
|
- Site hostname (bold) + "wants to connect to your wallet"
|
||||||
wallet"
|
|
||||||
- Address that will be shared (color dot + full address + etherscan link)
|
- Address that will be shared (color dot + full address + etherscan link)
|
||||||
- "Remember my choice for this site" checkbox
|
- "Remember my choice for this site" checkbox
|
||||||
- "Allow" / "Deny" buttons
|
- "Allow" / "Deny" buttons
|
||||||
@@ -1890,22 +1864,18 @@ view would leave a wallet one click from deletion.
|
|||||||
programmatically rather than by a user gesture. The background populates the
|
programmatically rather than by a user gesture. The background populates the
|
||||||
transaction (nonce, gas limit, fees, chain id) against the RPC node _before_
|
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
|
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
|
artifact can be compared with it field for field. A request that cannot be
|
||||||
ignored: the nonce is always the account's next nonce from the node, so a site
|
populated — unreachable node, reverting gas estimate — opens no window and is
|
||||||
cannot replace one of the user's pending transactions or leave this one stuck
|
failed back to the site. Only one transaction approval exists at a time:
|
||||||
behind a gap. A request that cannot be populated — unreachable node, reverting
|
populating fixes the nonce, so a second `eth_sendTransaction` arriving while
|
||||||
gas estimate — opens no window and is failed back to the site. Only one
|
one is unanswered is refused with EIP-1193 code `-32002` rather than being
|
||||||
transaction approval exists at a time: populating fixes the nonce, so a second
|
populated at the same nonce. It opens no window and takes no nonce, and the
|
||||||
`eth_sendTransaction` arriving while one is unanswered is refused with
|
site can send it again once the pending one is answered.
|
||||||
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**:
|
- **Elements**:
|
||||||
- "Transaction Request" heading
|
- "Transaction Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants to send a
|
- Site hostname (bold) + "wants to send a transaction"
|
||||||
transaction"
|
|
||||||
- Decoded action (if calldata is recognized): action name, token details,
|
- Decoded action (if calldata is recognized): action name, token details,
|
||||||
amounts, steps, deadline (see Transaction Decoding)
|
amounts, steps, deadline (see Transaction Decoding)
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
@@ -1931,32 +1901,19 @@ view would leave a wallet one click from deletion.
|
|||||||
|
|
||||||
- **When**: A connected website requests a message signature via
|
- **When**: A connected website requests a message signature via
|
||||||
`personal_sign`, `eth_sign`, or `eth_signTypedData_v4`. Opened the same way as
|
`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
|
TxApproval, in a separate popup window.
|
||||||
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.
|
|
||||||
- **Elements**:
|
- **Elements**:
|
||||||
- "Signature Request" heading
|
- "Signature Request" heading
|
||||||
- Phishing warning banner (shown when the hostname is on the phishing
|
- Phishing warning banner (shown when the hostname is on the phishing
|
||||||
blocklist)
|
blocklist)
|
||||||
- Site origin (bold, scheme and port included) + "wants you to sign a
|
- Site hostname (bold) + "wants you to sign a message"
|
||||||
message"
|
|
||||||
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
- Danger warning box (shown for `eth_sign`, which signs a raw hash)
|
||||||
- Type: "Personal message" or "Typed data (EIP-712)"
|
- Type: "Personal message" or "Typed data (EIP-712)"
|
||||||
- From: color dot + full address + etherscan link
|
- From: color dot + full address + etherscan link
|
||||||
- Message: for `personal_sign` and `eth_sign`, the text the message's bytes
|
- Message: decoded UTF-8 text (personal_sign) or formatted domain/type/
|
||||||
decode to as UTF-8, laid out left to right in the order of the bytes that
|
message fields (EIP-712 typed data). The primary type shown is the one
|
||||||
are signed, right-to-left characters included. Each control character,
|
ethers signs, derived from the typed data's `types`, not the type the site
|
||||||
each line or paragraph separator (U+2028, U+2029; left in the text, a
|
states.
|
||||||
paragraph separator would end that layout for the text after it), and each
|
|
||||||
character that paints nothing (format characters such as zero-width and
|
|
||||||
bidirectional ones, default-ignorable characters such as variation
|
|
||||||
selectors and Hangul fillers, and DELETE), is shown as a bordered `U+XXXX`
|
|
||||||
mark instead of acting on the text; a line feed is shown as a line break.
|
|
||||||
Bytes that are not UTF-8 are shown as "This message is not text." For
|
|
||||||
typed data, formatted domain/type/message fields (EIP-712). The primary
|
|
||||||
type shown is the one ethers signs, derived from the typed data's `types`,
|
|
||||||
not the type the site states.
|
|
||||||
- Token permission warning, at the top of the message (typed data whose
|
- Token permission warning, at the top of the message (typed data whose
|
||||||
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
primary type is `Permit`, as in EIP-2612, or one of Permit2's signature
|
||||||
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
types): "⚠️ TOKEN PERMISSION: Signing this lets the spender below take the
|
||||||
@@ -1968,20 +1925,12 @@ view would leave a wallet one click from deletion.
|
|||||||
domain's `verifyingContract`; any those fields do not give is shown as
|
domain's `verifyingContract`; any those fields do not give is shown as
|
||||||
`Unknown`, and the domain, type and message lines still follow. Only typed
|
`Unknown`, and the domain, type and message lines still follow. Only typed
|
||||||
data that cannot be read at all is shown as raw text.
|
data that cannot be read at all is shown as raw text.
|
||||||
- Raw data (`personal_sign` and `eth_sign`): the message's hex exactly as
|
|
||||||
the site sent it. The bytes it encodes are what is signed, as an EIP-191
|
|
||||||
personal message.
|
|
||||||
- Password input and an error line
|
- Password input and an error line
|
||||||
- "Sign" / "Reject" buttons
|
- "Sign" / "Reject" buttons
|
||||||
- **Transitions**:
|
- **Transitions**:
|
||||||
- Typed data that states no primary type, or one other than the type it
|
- Typed data that states no primary type, or one other than the type it
|
||||||
would be signed as, or that cannot be read → shown with the error line
|
would be signed as, or that cannot be read → shown with the error line
|
||||||
saying so and "Sign" disabled; only "Reject" remains
|
saying so and "Sign" disabled; only "Reject" remains
|
||||||
- A `personal_sign` or `eth_sign` message that is not hex (`0x` or `0X` and
|
|
||||||
an even number of hex digits, the form ethers' `getBytes` reads when
|
|
||||||
signing) → shown as plain text, with the error line "This message is plain
|
|
||||||
text, not hex, so it cannot be signed." and "Sign" disabled; signing takes
|
|
||||||
the bytes from the hex, so such a message has none to sign
|
|
||||||
- "Sign" (correct password) → signs locally → closes popup (returns
|
- "Sign" (correct password) → signs locally → closes popup (returns
|
||||||
signature)
|
signature)
|
||||||
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
- "Sign" (wrong password, or a signing failure) → error line, no screen
|
||||||
@@ -2202,17 +2151,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
|
constant directly, so no runtime toggle in a release build can reach the
|
||||||
hardcoded test phrase.
|
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
|
### Key Decisions
|
||||||
|
|
||||||
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
- **No framework**: The popup UI is vanilla JS and HTML. The extension is small
|
||||||
|
|||||||
@@ -45,90 +45,6 @@ but the review is broader than any of them.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-10-04: The swap decoder reads two router zeros the way the router does
|
|
||||||
([#415](https://git.eeqj.de/sneak/AutistMask/issues/415)). A V2 exact-in
|
|
||||||
`amountIn` of zero means an earlier step already sent the tokens to the pair;
|
|
||||||
`Amount` showed `0.0000` for it and now reads
|
|
||||||
`Whatever an earlier step sent to the pair (V2 already paid)`. A
|
|
||||||
`BALANCE_CHECK_ERC20` with a zero `minBalance` guarantees nothing, yet it
|
|
||||||
replaced the minimum an earlier swap step stated, so `Min. received` read
|
|
||||||
`None (no minimum guaranteed)`; it now sets the output side only when that
|
|
||||||
side holds no minimum at the point the check is reached. A nonzero
|
|
||||||
`minBalance` still sets the output side.
|
|
||||||
- 2026-10-04: The signature screen shows a personal message as the bytes that
|
|
||||||
are signed ([#403](https://git.eeqj.de/sneak/AutistMask/issues/403)). It
|
|
||||||
showed only the decoded text, with bidirectional and zero-width characters
|
|
||||||
acting on it, so a site could make the message read differently from what is
|
|
||||||
signed, and a message that was not hex was shown as NUL characters. The hex is
|
|
||||||
now shown as "Raw data" alongside the decoded text, the text is laid out left
|
|
||||||
to right in byte order, control characters, line and paragraph separators and
|
|
||||||
characters that paint nothing are shown as `U+XXXX` marks, and a message that
|
|
||||||
is not hex by the rule signing reads it with is shown as plain text with
|
|
||||||
"Sign" disabled, since such a message has no bytes to sign.
|
|
||||||
- 2026-10-04: A token that reports more than 80 decimal places has no known
|
|
||||||
scale ([#350](https://git.eeqj.de/sneak/AutistMask/issues/350)). The shared
|
|
||||||
scale check `toDecimals()` accepted any `uint8`, but `formatUnits()` throws
|
|
||||||
above 80, so such a token left a swap or an ERC-20 call on the approval screen
|
|
||||||
undecoded, with nothing saying why. The check now stops at 80, and both
|
|
||||||
approval paths show the base-unit amount with the scale stated as unknown. The
|
|
||||||
balance list and the history list use the same check, so the same token no
|
|
||||||
longer stops an address's token balances from refreshing or its history from
|
|
||||||
loading.
|
|
||||||
- 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
|
- 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
|
17-25s, measured in alternating runs before and after the change
|
||||||
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
|
([#428](https://git.eeqj.de/sneak/AutistMask/issues/428)). Each popup boot in
|
||||||
|
|||||||
+3
-5
@@ -285,13 +285,11 @@ not appear and may be permanently lost.
|
|||||||
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
AutistMask injects a standard `window.ethereum` provider (EIP-1193) into web
|
||||||
pages. When a site requests access to your wallet:
|
pages. When a site requests access to your wallet:
|
||||||
|
|
||||||
1. A popup appears showing the site's origin (its scheme, host and port, for
|
1. A popup appears showing the site's hostname and the address that will be
|
||||||
example `https://app.example`) and the address that will be shared.
|
shared.
|
||||||
2. Click "Allow" to connect or "Deny" to reject.
|
2. Click "Allow" to connect or "Deny" to reject.
|
||||||
3. Optionally check "Remember my choice for this site" to skip the prompt next
|
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
|
time.
|
||||||
`https://app.example` does not cover `http://app.example` or another port of
|
|
||||||
the same host, which ask again.
|
|
||||||
|
|
||||||
When a connected site requests a transaction, a separate approval popup appears
|
When a connected site requests a transaction, a separate approval popup appears
|
||||||
showing the transaction details (from, to, value, data, network fee, network and
|
showing the transaction details (from, to, value, data, network fee, network and
|
||||||
|
|||||||
+69
-139
@@ -57,13 +57,9 @@ const windowsNs = windowsApi();
|
|||||||
const actionNs = actionApi();
|
const actionNs = actionApi();
|
||||||
|
|
||||||
// Connected sites (in-memory, non-persisted): { "origin:address": true }
|
// 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 = {};
|
const connectedSites = {};
|
||||||
|
|
||||||
// Pending approval requests: { id: { origin, resolve } }
|
// Pending approval requests: { id: { origin, hostname, resolve } }
|
||||||
const pendingApprovals = {};
|
const pendingApprovals = {};
|
||||||
|
|
||||||
// One transaction approval at a time, wallet-wide.
|
// 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
|
// authority on a nonce the network has not accepted, which an abandoned
|
||||||
// approval then leaves a hole in.
|
// approval then leaves a hole in.
|
||||||
//
|
//
|
||||||
// Sign approvals do not take this slot: a signature consumes no nonce. They are
|
// Sign approvals are not gated: a signature consumes no nonce.
|
||||||
// limited per site instead; see findPendingApproval().
|
|
||||||
//
|
//
|
||||||
// The slot is null when free, and otherwise the handle of the request holding
|
// 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
|
// 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
|
// EIP-1474 "resource unavailable": the standard code for a request that is
|
||||||
// refused because another one is already pending.
|
// 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
|
// 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
|
// 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.
|
// 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
|
// 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
|
// 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;
|
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() {
|
function resetPopupUrl() {
|
||||||
toolbarPopupApprovalId = null;
|
|
||||||
if (actionNs && typeof actionNs.setPopup === "function") {
|
if (actionNs && typeof actionNs.setPopup === "function") {
|
||||||
actionNs.setPopup({ popup: "src/popup/index.html" });
|
actionNs.setPopup({ popup: "src/popup/index.html" });
|
||||||
}
|
}
|
||||||
@@ -485,36 +459,29 @@ async function openApprovalWindow(id) {
|
|||||||
|
|
||||||
// Open an approval popup and return a promise that resolves with the user decision.
|
// 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).
|
// Prefers the browser-action popup (anchored to toolbar, no macOS Space switch).
|
||||||
function requestApproval(origin) {
|
function requestApproval(origin, hostname) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = { id, origin, resolve, type: "site" };
|
pendingApprovals[id] = { id, origin, hostname, resolve };
|
||||||
|
|
||||||
if (actionNs && typeof actionNs.openPopup === "function") {
|
if (actionNs && typeof actionNs.openPopup === "function") {
|
||||||
showInToolbarPopup(id);
|
actionNs.setPopup({
|
||||||
|
popup: "src/popup/index.html?approval=" + id,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = actionNs.openPopup();
|
||||||
|
if (result && typeof result.catch === "function") {
|
||||||
|
result.catch(() => openApprovalWindow(id));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
openApprovalWindow(id);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
openApprovalWindow(id);
|
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,
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const result = actionNs.openPopup();
|
|
||||||
if (result && typeof result.catch === "function") {
|
|
||||||
result.catch(() => openApprovalWindow(id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
openApprovalWindow(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
// Open a tx-approval popup and return a promise that resolves with txHash or error.
|
||||||
// Uses windows.create() directly because tx approvals are triggered programmatically
|
// Uses windows.create() directly because tx approvals are triggered programmatically
|
||||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||||
@@ -528,12 +495,13 @@ function showInToolbarPopup(id) {
|
|||||||
// screen never named.
|
// screen never named.
|
||||||
// `slot` is the transaction-approval slot its caller holds. Handing the
|
// `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.
|
// 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) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
@@ -549,12 +517,13 @@ function requestTxApproval(origin, approvedTx, approvedFrom, slot) {
|
|||||||
// Uses windows.create() directly because sign approvals are triggered programmatically
|
// Uses windows.create() directly because sign approvals are triggered programmatically
|
||||||
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
// (from a dApp RPC call), not from a user gesture, so action.openPopup() is
|
||||||
// unreliable in this context.
|
// unreliable in this context.
|
||||||
function requestSignApproval(origin, signParams, approvedFrom) {
|
function requestSignApproval(origin, hostname, signParams, approvedFrom) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
pendingApprovals[id] = {
|
pendingApprovals[id] = {
|
||||||
id,
|
id,
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
approvedFrom,
|
approvedFrom,
|
||||||
resolve,
|
resolve,
|
||||||
@@ -632,11 +601,11 @@ runtime.onConnect.addListener((port) => {
|
|||||||
// in the worker — a balance refresh in flight, another site's approval — has
|
// 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
|
// gone on running the whole time. Loading here used to replace the very
|
||||||
// objects that work was holding.
|
// objects that work was holding.
|
||||||
async function rememberSiteChoice(field, address, origin) {
|
async function rememberSiteChoice(field, address, hostname) {
|
||||||
await updateState((s) => {
|
await updateState((s) => {
|
||||||
if (!s[field][address]) s[field][address] = [];
|
if (!s[field][address]) s[field][address] = [];
|
||||||
if (!s[field][address].includes(origin)) {
|
if (!s[field][address].includes(hostname)) {
|
||||||
s[field][address].push(origin);
|
s[field][address].push(hostname);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -649,11 +618,12 @@ async function handleConnectionRequest(origin) {
|
|||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const denied = s.deniedSites[activeAddress] || [];
|
const denied = s.deniedSites[activeAddress] || [];
|
||||||
|
|
||||||
// Check denied list
|
// Check denied list
|
||||||
if (denied.includes(origin)) {
|
if (denied.includes(hostname)) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: 4001,
|
code: 4001,
|
||||||
@@ -664,50 +634,25 @@ async function handleConnectionRequest(origin) {
|
|||||||
|
|
||||||
// Check allowed list or in-memory connected
|
// Check allowed list or in-memory connected
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [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
|
// Open approval popup
|
||||||
const decision = await requestApproval(origin);
|
const decision = await requestApproval(origin, hostname);
|
||||||
|
|
||||||
if (decision.approved) {
|
if (decision.approved) {
|
||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("allowedSites", activeAddress, origin);
|
await rememberSiteChoice("allowedSites", activeAddress, hostname);
|
||||||
} else {
|
} else {
|
||||||
connectedSites[origin + ":" + activeAddress] = true;
|
connectedSites[origin + ":" + activeAddress] = true;
|
||||||
}
|
}
|
||||||
return { result: [activeAddress] };
|
return { result: [activeAddress] };
|
||||||
} else {
|
} else {
|
||||||
if (decision.remember) {
|
if (decision.remember) {
|
||||||
await rememberSiteChoice("deniedSites", activeAddress, origin);
|
await rememberSiteChoice("deniedSites", activeAddress, hostname);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
@@ -753,9 +698,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) return { result: [] };
|
if (!activeAddress) return { result: [] };
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]
|
connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { result: [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.
|
// [TESTNET] banner under a user who believed they were on Sepolia.
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -859,9 +806,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (method === "wallet_getPermissions") {
|
if (method === "wallet_getPermissions") {
|
||||||
const s = await getState();
|
const s = await getState();
|
||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
const isConnected =
|
const isConnected =
|
||||||
allowed.includes(origin) ||
|
allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress];
|
connectedSites[origin + ":" + activeAddress];
|
||||||
if (!isConnected || !activeAddress) {
|
if (!isConnected || !activeAddress) {
|
||||||
return { result: [] };
|
return { result: [] };
|
||||||
@@ -887,9 +835,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
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.";
|
"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(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
);
|
);
|
||||||
@@ -942,9 +884,10 @@ async function handleRpc(method, params, origin) {
|
|||||||
if (!activeAddress)
|
if (!activeAddress)
|
||||||
return { error: { message: "No accounts available" } };
|
return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
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(
|
const decision = await requestSignApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
signParams,
|
signParams,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
);
|
);
|
||||||
@@ -1010,9 +946,10 @@ async function handleSendTransaction(params, origin) {
|
|||||||
const activeAddress = activeAddressOf(s);
|
const activeAddress = activeAddressOf(s);
|
||||||
if (!activeAddress) return { error: { message: "No accounts available" } };
|
if (!activeAddress) return { error: { message: "No accounts available" } };
|
||||||
|
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const allowed = s.allowedSites[activeAddress] || [];
|
const allowed = s.allowedSites[activeAddress] || [];
|
||||||
if (
|
if (
|
||||||
!allowed.includes(origin) &&
|
!allowed.includes(hostname) &&
|
||||||
!connectedSites[origin + ":" + activeAddress]
|
!connectedSites[origin + ":" + activeAddress]
|
||||||
) {
|
) {
|
||||||
return { error: { code: 4100, message: "Unauthorized" } };
|
return { error: { code: 4100, message: "Unauthorized" } };
|
||||||
@@ -1039,7 +976,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
if (!slot) {
|
if (!slot) {
|
||||||
return {
|
return {
|
||||||
error: {
|
error: {
|
||||||
code: APPROVAL_PENDING_CODE,
|
code: TX_APPROVAL_PENDING_CODE,
|
||||||
message: TX_APPROVAL_PENDING_MESSAGE,
|
message: TX_APPROVAL_PENDING_MESSAGE,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1086,6 +1023,7 @@ async function handleSendTransaction(params, origin) {
|
|||||||
|
|
||||||
const decision = await requestTxApproval(
|
const decision = await requestTxApproval(
|
||||||
origin,
|
origin,
|
||||||
|
hostname,
|
||||||
approvedTx,
|
approvedTx,
|
||||||
activeAddress,
|
activeAddress,
|
||||||
slot,
|
slot,
|
||||||
@@ -1159,9 +1097,10 @@ async function broadcastAccountsChanged() {
|
|||||||
}
|
}
|
||||||
for (const tab of tabs) {
|
for (const tab of tabs) {
|
||||||
const origin = tab.url ? new URL(tab.url).origin : "";
|
const origin = tab.url ? new URL(tab.url).origin : "";
|
||||||
|
const hostname = extractHostname(origin);
|
||||||
const hasPermission =
|
const hasPermission =
|
||||||
activeAddress &&
|
activeAddress &&
|
||||||
(allowed.includes(origin) ||
|
(allowed.includes(hostname) ||
|
||||||
connectedSites[origin + ":" + activeAddress]);
|
connectedSites[origin + ":" + activeAddress]);
|
||||||
// Same as chainChanged above: a tab without our content script
|
// Same as chainChanged above: a tab without our content script
|
||||||
// rejects, and that is expected rather than a fault.
|
// 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.
|
// Tell every open tab of a site Settings removed that it has no account.
|
||||||
async function broadcastSiteRemoved(origin) {
|
async function broadcastSiteRemoved(hostname) {
|
||||||
let tabs;
|
let tabs;
|
||||||
try {
|
try {
|
||||||
tabs = await tabsQuery({});
|
tabs = await tabsQuery({});
|
||||||
@@ -1182,7 +1121,7 @@ async function broadcastSiteRemoved(origin) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const tab of tabs) {
|
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, {
|
tabsSendMessage(tab.id, {
|
||||||
type: "AUTISTMASK_EVENT",
|
type: "AUTISTMASK_EVENT",
|
||||||
eventName: "accountsChanged",
|
eventName: "accountsChanged",
|
||||||
@@ -1349,29 +1288,18 @@ if (windowsNs && windowsNs.onRemoved) {
|
|||||||
// Listen for messages from content scripts and popup
|
// Listen for messages from content scripts and popup
|
||||||
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||||
if (msg.type === "AUTISTMASK_RPC") {
|
if (msg.type === "AUTISTMASK_RPC") {
|
||||||
// The origin is the one the browser reports for the sender, never one
|
// Derive origin from trusted sender info to prevent origin spoofing.
|
||||||
// the message carries. Firefox before 126 gives no sender.origin, so
|
// Chrome MV3 provides sender.origin; Firefox MV2 fallback uses sender.tab.url.
|
||||||
// the origin of sender.url is used: the frame that sent the message,
|
let trustedOrigin = msg.origin; // fallback only if sender info unavailable
|
||||||
// not the tab's page, which may be another site embedding that
|
if (sender.origin) {
|
||||||
// frame. With neither, the request is refused.
|
trustedOrigin = sender.origin;
|
||||||
let trustedOrigin = sender.origin;
|
} else if (sender.tab && sender.tab.url) {
|
||||||
if (!trustedOrigin && sender.url) {
|
|
||||||
try {
|
try {
|
||||||
trustedOrigin = new URL(sender.url).origin;
|
trustedOrigin = new URL(sender.tab.url).origin;
|
||||||
} catch {
|
} 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)
|
handleRpc(msg.method, msg.params, trustedOrigin)
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
sendResponse(response);
|
sendResponse(response);
|
||||||
@@ -1409,7 +1337,10 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
if (msg.type === "AUTISTMASK_GET_APPROVAL") {
|
||||||
const approval = pendingApprovals[msg.id];
|
const approval = pendingApprovals[msg.id];
|
||||||
if (approval) {
|
if (approval) {
|
||||||
const resp = { origin: approval.origin };
|
const resp = {
|
||||||
|
hostname: approval.hostname,
|
||||||
|
origin: approval.origin,
|
||||||
|
};
|
||||||
if (approval.type === "tx") {
|
if (approval.type === "tx") {
|
||||||
resp.type = "tx";
|
resp.type = "tx";
|
||||||
// The populated transaction, and the address it was raised
|
// The populated transaction, and the address it was raised
|
||||||
@@ -1424,9 +1355,7 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
resp.approvedFrom = approval.approvedFrom;
|
resp.approvedFrom = approval.approvedFrom;
|
||||||
}
|
}
|
||||||
// Flag if the requesting domain is on the phishing blocklist.
|
// Flag if the requesting domain is on the phishing blocklist.
|
||||||
resp.isPhishingDomain = isPhishingDomain(
|
resp.isPhishingDomain = isPhishingDomain(approval.hostname);
|
||||||
extractHostname(approval.origin),
|
|
||||||
);
|
|
||||||
sendResponse(resp);
|
sendResponse(resp);
|
||||||
} else {
|
} else {
|
||||||
sendResponse(null);
|
sendResponse(null);
|
||||||
@@ -1760,22 +1689,23 @@ runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|||||||
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
if (msg.type === "AUTISTMASK_GET_CONNECTED_SITES") {
|
||||||
sendResponse(
|
sendResponse(
|
||||||
Object.keys(connectedSites).map((key) =>
|
Object.keys(connectedSites).map((key) =>
|
||||||
key.slice(0, key.lastIndexOf(":")),
|
extractHostname(key.slice(0, key.lastIndexOf(":"))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings removed this site (msg.origin) and has already dropped its
|
// Settings removed this site and has already dropped its remembered
|
||||||
// remembered entries. Its connections approved without "Remember" end
|
// entries. Its connections approved without "Remember" end here, under
|
||||||
// here, under every address, and its open tabs are told it has no account.
|
// every address, and its open tabs are told it has no account.
|
||||||
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
if (msg.type === "AUTISTMASK_REMOVE_SITE") {
|
||||||
for (const key of Object.keys(connectedSites)) {
|
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];
|
delete connectedSites[key];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
broadcastSiteRemoved(msg.origin);
|
broadcastSiteRemoved(msg.hostname);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ window.addEventListener("message", (event) => {
|
|||||||
id,
|
id,
|
||||||
method,
|
method,
|
||||||
params,
|
params,
|
||||||
|
origin: location.origin,
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
if (response) {
|
if (response) {
|
||||||
|
|||||||
+3
-12
@@ -1561,7 +1561,7 @@
|
|||||||
with extreme caution.
|
with extreme caution.
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">
|
<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.
|
wants to send a transaction.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1662,7 +1662,7 @@
|
|||||||
funds. Proceed with extreme caution.
|
funds. Proceed with extreme caution.
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-2">
|
<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.
|
wants you to sign a message.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -1698,15 +1698,6 @@
|
|||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="approve-sign-hex-section" class="mb-3 hidden">
|
|
||||||
<div class="text-xs text-muted mb-1">Raw data</div>
|
|
||||||
<div
|
|
||||||
id="approve-sign-hex"
|
|
||||||
class="text-xs break-all"
|
|
||||||
style="max-height: 6rem; overflow-y: auto"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="block mb-1 text-xs">Password</label>
|
<label class="block mb-1 text-xs">Password</label>
|
||||||
<input
|
<input
|
||||||
@@ -1749,7 +1740,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p class="mb-2">
|
<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.
|
wants to connect to your wallet.
|
||||||
</p>
|
</p>
|
||||||
<div class="text-xs text-muted mb-1">
|
<div class="text-xs text-muted mb-1">
|
||||||
|
|||||||
@@ -64,13 +64,3 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A personal message on the signature screen is laid out left to right in
|
|
||||||
* the order of its bytes. Without this, right-to-left characters in it move
|
|
||||||
* the characters around them: `5`, U+05C3, `00` would read as `500`
|
|
||||||
* followed by U+05C3. A paragraph separator (U+2029) ends this layout for
|
|
||||||
* the text after it, so src/popup/views/approval.js shows one as a mark. */
|
|
||||||
.am-byte-order {
|
|
||||||
direction: ltr;
|
|
||||||
unicode-bidi: bidi-override;
|
|
||||||
}
|
|
||||||
|
|||||||
+14
-60
@@ -26,7 +26,6 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { getPrice, formatUsd } = require("../../shared/prices");
|
const { getPrice, formatUsd } = require("../../shared/prices");
|
||||||
const { ERC20_ABI } = require("../../shared/constants");
|
const { ERC20_ABI } = require("../../shared/constants");
|
||||||
const { INVISIBLE_CHARACTERS } = require("../../shared/symbolSpoof");
|
|
||||||
const {
|
const {
|
||||||
resolveTokenDecimals,
|
resolveTokenDecimals,
|
||||||
resolveTokenSymbol,
|
resolveTokenSymbol,
|
||||||
@@ -307,7 +306,7 @@ function showTxApproval(details) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
$("approve-tx-origin").textContent = details.origin;
|
$("approve-tx-hostname").textContent = details.hostname;
|
||||||
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
$("approve-tx-from").innerHTML = approvalAddressHtml(details.approvedFrom);
|
||||||
|
|
||||||
// Show token symbol next to contract address if known
|
// Show token symbol next to contract address if known
|
||||||
@@ -381,48 +380,20 @@ function showTxApproval(details) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Whether a personal message is hex by the rule signing reads it with:
|
|
||||||
// signing takes getBytes(message), which throws on anything else.
|
|
||||||
function isHexMessage(message) {
|
|
||||||
try {
|
|
||||||
getBytes(message);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text the hex message's bytes decode to as UTF-8, or null when they are
|
|
||||||
// not UTF-8. The caller has checked that the message is hex.
|
|
||||||
function decodeHexMessage(hex) {
|
function decodeHexMessage(hex) {
|
||||||
try {
|
try {
|
||||||
return toUtf8String(getBytes(hex));
|
const bytes = Uint8Array.from(
|
||||||
|
hex
|
||||||
|
.slice(2)
|
||||||
|
.match(/.{1,2}/g)
|
||||||
|
.map((b) => parseInt(b, 16)),
|
||||||
|
);
|
||||||
|
return toUtf8String(bytes);
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A character shown as a bordered U+XXXX mark.
|
|
||||||
function codePointMark(c) {
|
|
||||||
const code = c.codePointAt(0).toString(16).toUpperCase();
|
|
||||||
return `<span class="border border-border">U+${code.padStart(4, "0")}</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The text as HTML, with each character that paints nothing (zero-width and
|
|
||||||
// bidirectional characters, variation selectors and Hangul fillers among
|
|
||||||
// them), each control character and each line or paragraph separator
|
|
||||||
// (U+2028, U+2029) shown as a mark. A line feed is shown as a line break.
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order layout
|
|
||||||
// for everything after it. The marks are plain ASCII, so the second pass
|
|
||||||
// leaves them be.
|
|
||||||
function markInvisibleCharacters(text) {
|
|
||||||
return escapeHtml(text)
|
|
||||||
.replace(INVISIBLE_CHARACTERS, codePointMark)
|
|
||||||
.replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, (c) =>
|
|
||||||
c === "\n" ? "<br>" : codePointMark(c),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The type ethers will sign typed data as. ethers does not read the page's
|
// The type ethers will sign typed data as. ethers does not read the page's
|
||||||
// `primaryType`: it takes the one struct in `types` that no other struct
|
// `primaryType`: it takes the one struct in `types` that no other struct
|
||||||
// refers to. Throws when the types name no such single struct, which ethers
|
// refers to. Throws when the types name no such single struct, which ethers
|
||||||
@@ -674,7 +645,7 @@ function showSignApproval(details) {
|
|||||||
pendingSignParams = sp;
|
pendingSignParams = sp;
|
||||||
pendingSignFrom = details.approvedFrom;
|
pendingSignFrom = details.approvedFrom;
|
||||||
|
|
||||||
$("approve-sign-origin").textContent = details.origin;
|
$("approve-sign-hostname").textContent = details.hostname;
|
||||||
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
$("approve-sign-from").innerHTML = approvalAddressHtml(
|
||||||
details.approvedFrom,
|
details.approvedFrom,
|
||||||
);
|
);
|
||||||
@@ -686,33 +657,15 @@ function showSignApproval(details) {
|
|||||||
? "Typed data (EIP-712)"
|
? "Typed data (EIP-712)"
|
||||||
: "Personal message";
|
: "Personal message";
|
||||||
|
|
||||||
// A personal message is signed as the bytes its hex encodes, so the hex
|
|
||||||
// is shown as well as any text it decodes to, and that text is laid out
|
|
||||||
// left to right in the order of its bytes. Signing reads the bytes from
|
|
||||||
// the hex, so a message that is not hex cannot be signed: it is shown as
|
|
||||||
// the text it is, and refused.
|
|
||||||
let refusal = null;
|
|
||||||
$("approve-sign-hex-section").classList.add("hidden");
|
|
||||||
$("approve-sign-message").classList.toggle("am-byte-order", !isTyped);
|
|
||||||
if (isTyped) {
|
if (isTyped) {
|
||||||
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
$("approve-sign-message").innerHTML = formatTypedDataHtml(sp.typedData);
|
||||||
refusal = typedDataRefusal(sp);
|
} else {
|
||||||
} else if (isHexMessage(sp.message)) {
|
|
||||||
const decoded = decodeHexMessage(sp.message);
|
const decoded = decodeHexMessage(sp.message);
|
||||||
if (decoded !== null) {
|
if (decoded !== null) {
|
||||||
$("approve-sign-message").innerHTML =
|
$("approve-sign-message").textContent = decoded;
|
||||||
markInvisibleCharacters(decoded);
|
|
||||||
} else {
|
} else {
|
||||||
$("approve-sign-message").textContent = "This message is not text.";
|
$("approve-sign-message").textContent = sp.message;
|
||||||
}
|
}
|
||||||
$("approve-sign-hex").textContent = sp.message;
|
|
||||||
$("approve-sign-hex-section").classList.remove("hidden");
|
|
||||||
} else {
|
|
||||||
$("approve-sign-message").innerHTML = markInvisibleCharacters(
|
|
||||||
sp.message,
|
|
||||||
);
|
|
||||||
refusal =
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display danger warning for eth_sign (raw hash signing)
|
// Display danger warning for eth_sign (raw hash signing)
|
||||||
@@ -734,6 +687,7 @@ function showSignApproval(details) {
|
|||||||
|
|
||||||
showView("approve-sign");
|
showView("approve-sign");
|
||||||
attachCopyHandlers("view-approve-sign");
|
attachCopyHandlers("view-approve-sign");
|
||||||
|
const refusal = typedDataRefusal(sp);
|
||||||
if (refusal) {
|
if (refusal) {
|
||||||
showError("approve-sign-error", refusal);
|
showError("approve-sign-error", refusal);
|
||||||
$("btn-approve-sign").disabled = true;
|
$("btn-approve-sign").disabled = true;
|
||||||
@@ -778,7 +732,7 @@ async function show(id) {
|
|||||||
"approve-site-phishing-warning",
|
"approve-site-phishing-warning",
|
||||||
details.isPhishingDomain,
|
details.isPhishingDomain,
|
||||||
);
|
);
|
||||||
$("approve-origin").textContent = details.origin;
|
$("approve-hostname").textContent = details.hostname;
|
||||||
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
|
||||||
attachCopyHandlers("view-approve-site");
|
attachCopyHandlers("view-approve-site");
|
||||||
$("approve-remember").checked = state.rememberSiteChoice;
|
$("approve-remember").checked = state.rememberSiteChoice;
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ async function estimateGas(txInfo) {
|
|||||||
feeWei = gasCostWei;
|
feeWei = gasCostWei;
|
||||||
renderValidation(txInfo);
|
renderValidation(txInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("gas estimation failed:", e.shortMessage || e.message);
|
log.errorf("gas estimation failed:", e.message);
|
||||||
if (pendingTx !== txInfo) return;
|
if (pendingTx !== txInfo) return;
|
||||||
$("confirm-fee-amount").textContent = "Unable to estimate";
|
$("confirm-fee-amount").textContent = "Unable to estimate";
|
||||||
setVisible("confirm-fee-reserve", false);
|
setVisible("confirm-fee-reserve", false);
|
||||||
|
|||||||
+22
-31
@@ -16,12 +16,7 @@ const {
|
|||||||
} = require("../dustThreshold");
|
} = require("../dustThreshold");
|
||||||
const { state, saveState, currentNetwork } = require("../../shared/state");
|
const { state, saveState, currentNetwork } = require("../../shared/state");
|
||||||
const { onChainSwitch } = require("../../shared/chainSwitch");
|
const { onChainSwitch } = require("../../shared/chainSwitch");
|
||||||
const {
|
const { log, debugFetch, setRuntimeDebug } = require("../../shared/log");
|
||||||
log,
|
|
||||||
debugFetch,
|
|
||||||
urlOrigin,
|
|
||||||
setRuntimeDebug,
|
|
||||||
} = require("../../shared/log");
|
|
||||||
const deleteWallet = require("./deleteWallet");
|
const deleteWallet = require("./deleteWallet");
|
||||||
const showPhrase = require("./showPhrase");
|
const showPhrase = require("./showPhrase");
|
||||||
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
const { walletHasRecoveryPhrase } = require("../../shared/wallet");
|
||||||
@@ -39,35 +34,35 @@ const { notify, sendMessage } = require("../../shared/browserApi");
|
|||||||
let versionClickCount = 0;
|
let versionClickCount = 0;
|
||||||
let versionClickTimer = null;
|
let versionClickTimer = null;
|
||||||
|
|
||||||
// One row per site origin, however many addresses it appears under, each with
|
// One row per hostname, however many addresses or origins it appears under,
|
||||||
// an [x] that hands it to onRemove.
|
// each with an [x] that hands it to onRemove.
|
||||||
function renderSiteList(containerId, origins, onRemove) {
|
function renderSiteList(containerId, hostnames, onRemove) {
|
||||||
const container = $(containerId);
|
const container = $(containerId);
|
||||||
const unique = [...new Set(origins)];
|
const unique = [...new Set(hostnames)];
|
||||||
if (unique.length === 0) {
|
if (unique.length === 0) {
|
||||||
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
container.innerHTML = '<p class="text-xs text-muted">None</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let html = "";
|
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">`;
|
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
|
// this is escaped for the rule rather than for a known hole — the
|
||||||
// rule being that nothing reaches innerHTML unescaped.
|
// rule being that nothing reaches innerHTML unescaped.
|
||||||
html += `<span>${escapeHtml(origin)}</span>`;
|
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-origin="${escapeHtml(origin)}">[x]</button>`;
|
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>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
container.querySelectorAll(".btn-remove-site").forEach((btn) => {
|
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.
|
// Drop a hostname from a remembered site list under every address.
|
||||||
function forgetOrigin(siteMap, origin) {
|
function forgetHostname(siteMap, hostname) {
|
||||||
for (const addr of Object.keys(siteMap)) {
|
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) {
|
if (siteMap[addr].length === 0) {
|
||||||
delete siteMap[addr];
|
delete siteMap[addr];
|
||||||
}
|
}
|
||||||
@@ -77,16 +72,16 @@ function forgetOrigin(siteMap, origin) {
|
|||||||
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
// Removing a site from Allowed Sites or Connected Sites disconnects it: it is
|
||||||
// no longer allowed under any address, and the background ends its
|
// no longer allowed under any address, and the background ends its
|
||||||
// connections approved without "Remember" and tells its open tabs.
|
// connections approved without "Remember" and tells its open tabs.
|
||||||
async function removeAllowedSite(origin) {
|
async function removeAllowedSite(hostname) {
|
||||||
forgetOrigin(state.allowedSites, origin);
|
forgetHostname(state.allowedSites, hostname);
|
||||||
await saveState();
|
await saveState();
|
||||||
notify({ type: "AUTISTMASK_REMOVE_SITE", origin });
|
notify({ type: "AUTISTMASK_REMOVE_SITE", hostname });
|
||||||
await renderSiteLists();
|
await renderSiteLists();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing a denied site only forgets the refusal; it connects nothing.
|
// Removing a denied site only forgets the refusal; it connects nothing.
|
||||||
async function removeDeniedSite(origin) {
|
async function removeDeniedSite(hostname) {
|
||||||
forgetOrigin(state.deniedSites, origin);
|
forgetHostname(state.deniedSites, hostname);
|
||||||
await saveState();
|
await saveState();
|
||||||
await renderSiteLists();
|
await renderSiteLists();
|
||||||
}
|
}
|
||||||
@@ -277,11 +272,8 @@ function init(ctx) {
|
|||||||
showFlash("Wrong network: expected " + net.name + ".");
|
showFlash("Wrong network: expected " + net.name + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Not the error's message: fetch puts the whole URL, password and
|
log.errorf("RPC validation fetch failed:", e.message);
|
||||||
// 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));
|
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -303,9 +295,8 @@ function init(ctx) {
|
|||||||
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
showFlash("Endpoint returned HTTP " + resp.status + ".");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Not the error's message, as for the RPC check above.
|
log.errorf("Blockscout validation failed:", e.message);
|
||||||
log.errorf("Blockscout validation failed:", urlOrigin(url));
|
|
||||||
showFlash("Could not reach endpoint.");
|
showFlash("Could not reach endpoint.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
|
|||||||
// failed — which matters most on a resumed wait, where the
|
// failed — which matters most on a resumed wait, where the
|
||||||
// first poll is already past the deadline.
|
// first poll is already past the deadline.
|
||||||
answered = false;
|
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
|
// The lookup is async: the wait may have ended while it was in
|
||||||
// flight, in which case this result must not touch the view.
|
// flight, in which case this result must not touch the view.
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
// Skip tx count check for contracts — they may legitimately have
|
||||||
@@ -92,7 +92,7 @@ async function getFullWarnings(address, provider, options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf("tx count check failed:", e.shortMessage || e.message);
|
log.errorf("tx count check failed:", e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,11 @@
|
|||||||
// disputed is refused rather than guessed at.
|
// disputed is refused rather than guessed at.
|
||||||
|
|
||||||
// Solidity's decimals() is a uint8, and every source here is ultimately
|
// Solidity's decimals() is a uint8, and every source here is ultimately
|
||||||
// reporting that call's result. toDecimals() is that check, stopping at the 80
|
// reporting that call's result. toDecimals() is that check, shared with the
|
||||||
// places formatUnits() accepts, and shared with the send path rather than
|
// send path rather than copied: the bundled list stores numbers, the
|
||||||
// copied: the bundled list stores numbers, the explorer's copy arrives as a
|
// explorer's copy arrives as a string, and a token the user added by hand
|
||||||
// string, and a token the user added by hand carries whatever lookupTokenInfo()
|
// carries whatever lookupTokenInfo() got back, so the accepted types are
|
||||||
// got back, so the accepted types are enumerated rather than coerced.
|
// enumerated rather than coerced.
|
||||||
const { toDecimals } = require("./transferAmount");
|
const { toDecimals } = require("./transferAmount");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { isSpoofedSymbol } = require("./symbolSpoof");
|
const { isSpoofedSymbol } = require("./symbolSpoof");
|
||||||
|
|||||||
@@ -51,15 +51,11 @@ const POPULATE_TIMEOUT_MS = 20000;
|
|||||||
// passed to ethers: the object is page-controlled, and a future ethers that
|
// 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
|
// learns to carry a new transaction field must not start picking one up out of
|
||||||
// it without this module knowing.
|
// 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 = [
|
const REQUEST_FIELDS = [
|
||||||
"to",
|
"to",
|
||||||
"value",
|
"value",
|
||||||
"data",
|
"data",
|
||||||
|
"nonce",
|
||||||
"gasLimit",
|
"gasLimit",
|
||||||
"gasPrice",
|
"gasPrice",
|
||||||
"maxFeePerGas",
|
"maxFeePerGas",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const {
|
|||||||
} = require("ethers");
|
} = require("ethers");
|
||||||
const { ERC20_ABI } = require("./constants");
|
const { ERC20_ABI } = require("./constants");
|
||||||
const { NETWORKS } = require("./networks");
|
const { NETWORKS } = require("./networks");
|
||||||
const { log, debugFetch, urlOrigin } = require("./log");
|
const { log, debugFetch } = require("./log");
|
||||||
const { deriveAddressFromXpub } = require("./wallet");
|
const { deriveAddressFromXpub } = require("./wallet");
|
||||||
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
const { TOKEN_BY_ADDRESS } = require("./tokenList");
|
||||||
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
const { LOW_HOLDER_THRESHOLD, parseHoldersCount } = require("./holders");
|
||||||
@@ -203,7 +203,7 @@ async function refreshBalances(
|
|||||||
trackedTokens,
|
trackedTokens,
|
||||||
networkId,
|
networkId,
|
||||||
) {
|
) {
|
||||||
log.debugf("refreshBalances start, rpc:", urlOrigin(rpcUrl));
|
log.debugf("refreshBalances start, rpc:", rpcUrl);
|
||||||
const provider = getProvider(rpcUrl, networkId);
|
const provider = getProvider(rpcUrl, networkId);
|
||||||
const updates = [];
|
const updates = [];
|
||||||
|
|
||||||
@@ -246,7 +246,7 @@ async function refreshBalances(
|
|||||||
log.errorf(
|
log.errorf(
|
||||||
"ENS reverse failed",
|
"ENS reverse failed",
|
||||||
addr.address,
|
addr.address,
|
||||||
e.shortMessage || e.message,
|
e.message,
|
||||||
);
|
);
|
||||||
// Keep existing addr.ensName if we had one
|
// Keep existing addr.ensName if we had one
|
||||||
}),
|
}),
|
||||||
@@ -280,7 +280,7 @@ async function refreshBalances(
|
|||||||
// Look up token metadata from its contract.
|
// Look up token metadata from its contract.
|
||||||
// Calls symbol() and decimals() to verify it implements ERC-20.
|
// Calls symbol() and decimals() to verify it implements ERC-20.
|
||||||
async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
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 provider = getProvider(rpcUrl, networkId);
|
||||||
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
const contract = new Contract(contractAddress, ERC20_ABI, provider);
|
||||||
|
|
||||||
@@ -305,10 +305,7 @@ async function lookupTokenInfo(contractAddress, rpcUrl, networkId) {
|
|||||||
name = await contract.name();
|
name = await contract.name();
|
||||||
log.debugf("name() =", name);
|
log.debugf("name() =", name);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.warnf(
|
log.warnf("name() failed, using symbol as name:", e.message);
|
||||||
"name() failed, using symbol as name:",
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
name = symbol;
|
name = symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-5
@@ -42,11 +42,7 @@ async function resolveEnsName(address, rpcUrl, networkId) {
|
|||||||
setCache(address, name);
|
setCache(address, name);
|
||||||
return name;
|
return name;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.errorf(
|
log.errorf("ENS reverse lookup failed", address, e.message);
|
||||||
"ENS reverse lookup failed",
|
|
||||||
address,
|
|
||||||
e.shortMessage || e.message,
|
|
||||||
);
|
|
||||||
// Don't cache failures — let subsequent lookups retry
|
// Don't cache failures — let subsequent lookups retry
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-25
@@ -42,34 +42,14 @@ const log = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// The origin (scheme, host and port) of a URL, for logging in place of the
|
// Fetch wrapper that debug-logs every request and response.
|
||||||
// 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.
|
|
||||||
async function debugFetch(url, opts) {
|
async function debugFetch(url, opts) {
|
||||||
const method = (opts && opts.method) || "GET";
|
const method = (opts && opts.method) || "GET";
|
||||||
const origin = urlOrigin(url);
|
const body = opts && opts.body;
|
||||||
let rpcMethod = "";
|
log.debugf("fetch →", method, url, body || "");
|
||||||
try {
|
|
||||||
rpcMethod = JSON.parse(opts.body).method || "";
|
|
||||||
} catch {
|
|
||||||
// no body, or a body that is not JSON
|
|
||||||
}
|
|
||||||
log.debugf("fetch →", method, origin, rpcMethod);
|
|
||||||
const resp = await fetch(url, opts);
|
const resp = await fetch(url, opts);
|
||||||
log.debugf("fetch ←", resp.status, origin);
|
log.debugf("fetch ←", resp.status, url);
|
||||||
return resp;
|
return resp;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { log, debugFetch, urlOrigin, setRuntimeDebug, isDebug };
|
module.exports = { log, debugFetch, setRuntimeDebug, isDebug };
|
||||||
|
|||||||
@@ -102,11 +102,11 @@ function tokenRefs(value) {
|
|||||||
|
|
||||||
// A list of strings, for the fields whose entries are dereferenced as text:
|
// A list of strings, for the fields whose entries are dereferenced as text:
|
||||||
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
// fraudContracts (`a.toLowerCase()` in src/popup/views/send.js and
|
||||||
// src/shared/transactions.js) and each address's origin list in the site maps
|
// src/shared/transactions.js) and each address's hostname list in the site maps
|
||||||
// below (`o !== origin` filters, `list.includes(origin)` in the background).
|
// below (`h !== host` filters, `list.includes(hostname)` in the background).
|
||||||
//
|
//
|
||||||
// Same rule as tokenRefs(), for the same reason: the container AND the entries,
|
// 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
|
// 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
|
// 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
|
// 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");
|
return value.filter((entry) => typeof entry === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
// allowedSites / deniedSites: { [address]: [origin, ...] }, each origin the
|
// allowedSites / deniedSites: { [address]: [hostname, ...] }.
|
||||||
// full scheme://host[:port] of a site.
|
|
||||||
//
|
//
|
||||||
// The container check these had (truthy and not an array) is not the floor:
|
// 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
|
// `{"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
|
// 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
|
// 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
|
// 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
|
// healthy (https://git.eeqj.de/sneak/AutistMask/issues/362). The Settings
|
||||||
// revoke button (`list.filter()`), and the background's
|
// 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
|
// one a stored string would also answer a SUBSTRING match, so a corrupt map
|
||||||
// could widen a site permission rather than merely throw.
|
// 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
|
// 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
|
// "__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
|
// 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;
|
if (!isRecord(value)) return out;
|
||||||
for (const address of Object.keys(value)) {
|
for (const address of Object.keys(value)) {
|
||||||
if (address === "__proto__") continue;
|
if (address === "__proto__") continue;
|
||||||
const origins = textList(value[address]);
|
const hostnames = textList(value[address]);
|
||||||
if (origins.length === 0) continue;
|
if (hostnames.length === 0) continue;
|
||||||
defineOwn(out, address, origins);
|
defineOwn(out, address, hostnames);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-10
@@ -304,7 +304,7 @@ function mergeAddress(base, ours, theirs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge a plain object keyed by string (allowedSites/deniedSites: address ->
|
// 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-
|
// same way mergeListByIdentity() merges an array — by key, not by whole-
|
||||||
// object diff — so a key one page added or removed applies independently of
|
// 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
|
// 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;
|
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
|
// list is itself membership, not a leaf — the background appends a newly
|
||||||
// approved/denied origin to it, and the Settings "revoke" button
|
// approved/denied hostname to it, and the Settings "revoke" button
|
||||||
// (src/popup/views/settings.js) filters an origin out of it in place, from a
|
// (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
|
// 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.
|
// pick.
|
||||||
function mergeOriginList(base, ours, theirs) {
|
function mergeHostnameList(base, ours, theirs) {
|
||||||
return mergeListByIdentity(
|
return mergeListByIdentity(
|
||||||
base,
|
base,
|
||||||
ours,
|
ours,
|
||||||
theirs,
|
theirs,
|
||||||
(origin) => origin,
|
(hostname) => hostname,
|
||||||
(b, o, t) => t,
|
(b, o, t) => t,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeSiteMap(base, ours, theirs) {
|
function mergeSiteMap(base, ours, theirs) {
|
||||||
return mergeMapByKey(base, ours, theirs, mergeOriginList);
|
return mergeMapByKey(base, ours, theirs, mergeHostnameList);
|
||||||
}
|
}
|
||||||
|
|
||||||
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
// networkEndpoints: { [networkId]: {rpcUrl, blockscoutUrl} }.
|
||||||
@@ -422,8 +422,8 @@ function mergeNetworkEndpoints(base, ours, theirs) {
|
|||||||
// address) apply independently instead of colliding as the same field.
|
// address) apply independently instead of colliding as the same field.
|
||||||
//
|
//
|
||||||
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
// `allowedSites` and `deniedSites` get the same treatment (mergeSiteMap(),
|
||||||
// by address key and then by origin within each address's list), for the
|
// by address key and then by hostname within each address's list), for the
|
||||||
// identical reason: the background appends a newly approved/denied origin
|
// identical reason: the background appends a newly approved/denied hostname
|
||||||
// to them, and the Settings "revoke" button (src/popup/views/settings.js)
|
// 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
|
// 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
|
// doesn't just lose data, it is a security defect — a stale page's save can
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ function normalizeAddress(addr) {
|
|||||||
return (addr || "").toLowerCase();
|
return (addr || "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The characters that paint nothing; normalizeSymbol below says which they
|
|
||||||
// are. The signature screen marks them in a personal message
|
|
||||||
// (src/popup/views/approval.js).
|
|
||||||
const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|
||||||
|
|
||||||
// Fold a symbol onto what a user actually sees, and no further:
|
// Fold a symbol onto what a user actually sees, and no further:
|
||||||
//
|
//
|
||||||
// NFKC collapses compatibility variants that render as the ASCII
|
// NFKC collapses compatibility variants that render as the ASCII
|
||||||
@@ -87,7 +82,7 @@ const INVISIBLE_CHARACTERS = /[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu;
|
|||||||
function normalizeSymbol(symbol) {
|
function normalizeSymbol(symbol) {
|
||||||
return String(symbol || "")
|
return String(symbol || "")
|
||||||
.normalize("NFKC")
|
.normalize("NFKC")
|
||||||
.replace(INVISIBLE_CHARACTERS, "")
|
.replace(/[\p{Cf}\p{Default_Ignorable_Code_Point}\x7F]/gu, "")
|
||||||
.trim()
|
.trim()
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -109,6 +104,5 @@ function isSpoofedSymbol(symbol, contractAddress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
INVISIBLE_CHARACTERS,
|
|
||||||
isSpoofedSymbol,
|
isSpoofedSymbol,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,11 +27,9 @@
|
|||||||
|
|
||||||
const { parseUnits } = require("ethers");
|
const { parseUnits } = require("ethers");
|
||||||
|
|
||||||
// Solidity's decimals() returns a uint8, but ethers' formatUnits() and
|
// Solidity's decimals() returns a uint8, so anything outside that range is not
|
||||||
// parseUnits() refuse more than 80 decimal places ("invalid FixedNumber
|
// an answer this wallet can use.
|
||||||
// decimals (too large)"). A scale of 81 to 255 can be neither displayed nor
|
const MAX_DECIMALS = 255;
|
||||||
// encoded, so it is not an answer this wallet can use, the same as no answer.
|
|
||||||
const MAX_DECIMALS = 80;
|
|
||||||
|
|
||||||
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
|
const UNKNOWN_DISPLAYED_DECIMALS_MESSAGE =
|
||||||
"The transfer was not sent, because the number of decimal places this" +
|
"The transfer was not sent, because the number of decimal places this" +
|
||||||
@@ -57,7 +55,7 @@ function mismatchMessage(displayed, onChain) {
|
|||||||
// A decimals value from any source as a number, or null if it is not one.
|
// A decimals value from any source as a number, or null if it is not one.
|
||||||
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
// decimals() comes back from ethers as a bigint and the explorer's copy arrives
|
||||||
// as a string, so both of those are accepted alongside a plain number; anything
|
// as a string, so both of those are accepted alongside a plain number; anything
|
||||||
// fractional, negative, above MAX_DECIMALS, or of any other type at all is not.
|
// fractional, negative, out of uint8 range, or of any other type at all is not.
|
||||||
//
|
//
|
||||||
// The types are enumerated rather than coerced because Number() is far too
|
// The types are enumerated rather than coerced because Number() is far too
|
||||||
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
// willing: Number([]) is 0 and Number(true) is 1, so a coercing check would
|
||||||
|
|||||||
+14
-41
@@ -84,31 +84,17 @@ function present(value) {
|
|||||||
//
|
//
|
||||||
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
// `amountOutMinimum` gets no such mapping: V4Router compares it directly
|
||||||
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
// (`if (amountOut < params.amountOutMinimum) revert V4TooLittleReceived`), so
|
||||||
// a zero minimum is a literal zero slippage floor and is stated as one. Nor
|
// a zero minimum is a literal zero slippage floor and is stated as one. Nor do
|
||||||
// does the V3 path have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
// the V2/V3 paths have it — universal-router's `V3SwapRouter.v3SwapExactInput`
|
||||||
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
// special-cases only `ActionConstants.CONTRACT_BALANCE` (1<<255), never zero —
|
||||||
// so a zero V3 `amountIn` is a literal zero and is displayed as one. The V2
|
// so a zero `amountIn` there is a literal zero and is displayed as one.
|
||||||
// exact-in path gives zero a meaning of its own: see ALREADY_PAID.
|
|
||||||
const OPEN_DELTA = Symbol("v4-open-delta");
|
const OPEN_DELTA = Symbol("v4-open-delta");
|
||||||
|
|
||||||
// The Universal Router's V2 exact-in spells "the pair already holds the input
|
// The two amount lines that state a fact instead of a quantity. Same register
|
||||||
// tokens" as an amount of zero: universal-router
|
// as UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread
|
||||||
// `contracts/libraries/Constants.sol` declares
|
// as a number — and deliberately not a third phrasing of "not named": these
|
||||||
// `uint256 internal constant ALREADY_PAID = 0` ("Used for identifying cases
|
// say different things.
|
||||||
// when a v2 pair has already received input tokens"), and
|
|
||||||
// `V2SwapRouter.v2SwapExactInput` makes no payment of its own when `amountIn`
|
|
||||||
// equals it. The swap then spends whatever an earlier step sent to the pair.
|
|
||||||
// As with OPEN_DELTA, the calldata states no quantity, and "0.0000" would say
|
|
||||||
// that nothing is swapped.
|
|
||||||
const ALREADY_PAID = Symbol("v2-already-paid");
|
|
||||||
|
|
||||||
// The amount lines that state a fact instead of a quantity. Same register as
|
|
||||||
// UNNAMED_CURRENCY — a sentence in the value slot, so it cannot be misread as a
|
|
||||||
// number — and deliberately not another phrasing of "not named": these say
|
|
||||||
// different things.
|
|
||||||
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
const OPEN_DELTA_AMOUNT = "All available (V4 open delta)";
|
||||||
const ALREADY_PAID_AMOUNT =
|
|
||||||
"Whatever an earlier step sent to the pair (V2 already paid)";
|
|
||||||
const NO_MINIMUM = "None (no minimum guaranteed)";
|
const NO_MINIMUM = "None (no minimum guaranteed)";
|
||||||
|
|
||||||
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
// Permit2 amounts are uint160; the maximum is Permit2's "unbounded".
|
||||||
@@ -199,7 +185,6 @@ function decodeBalanceCheck(input) {
|
|||||||
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
// Decode V2_SWAP_EXACT_IN (command 0x08) input bytes.
|
||||||
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
// ABI: (address recipient, uint256 amountIn, uint256 amountOutMin,
|
||||||
// address[] path, bool payerIsUser)
|
// address[] path, bool payerIsUser)
|
||||||
// A zero `amountIn` is read the way the router reads it, as ALREADY_PAID.
|
|
||||||
function decodeV2SwapExactIn(input) {
|
function decodeV2SwapExactIn(input) {
|
||||||
try {
|
try {
|
||||||
const d = coder.decode(
|
const d = coder.decode(
|
||||||
@@ -207,7 +192,7 @@ function decodeV2SwapExactIn(input) {
|
|||||||
input,
|
input,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
amountIn: d[1] === 0n ? ALREADY_PAID : d[1],
|
amountIn: d[1],
|
||||||
amountOutMin: d[2],
|
amountOutMin: d[2],
|
||||||
tokenIn: d[3][0],
|
tokenIn: d[3][0],
|
||||||
tokenOut: d[3][d[3].length - 1],
|
tokenOut: d[3][d[3].length - 1],
|
||||||
@@ -517,13 +502,7 @@ function decode(data, toAddress, sources) {
|
|||||||
|
|
||||||
if (cmdId === 0x0e) {
|
if (cmdId === 0x0e) {
|
||||||
const b = decodeBalanceCheck(inputs[i]);
|
const b = decodeBalanceCheck(inputs[i]);
|
||||||
// The router passes this check whenever the owner holds at
|
if (b) setOutput(b.token, b.minBalance);
|
||||||
// least minBalance, so a zero one guarantees nothing and
|
|
||||||
// does not replace a minimum an earlier step stated. Any
|
|
||||||
// other minBalance sets the output side as a swap does.
|
|
||||||
if (b && !(b.minBalance === 0n && present(minOutput))) {
|
|
||||||
setOutput(b.token, b.minBalance);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdId === 0x00) {
|
if (cmdId === 0x00) {
|
||||||
@@ -644,20 +623,14 @@ function decode(data, toAddress, sources) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (present(inputAmount)) {
|
if (present(inputAmount)) {
|
||||||
// Three amounts need no scale to describe and are named rather
|
// Two amounts need no scale to describe and are named rather than
|
||||||
// than formatted: V4's open delta and V2's already-paid zero,
|
// formatted: V4's open delta, which is not a quantity at all (see
|
||||||
// neither of which is a quantity at all (see OPEN_DELTA and
|
// OPEN_DELTA), and an unbounded permit. The open-delta test comes
|
||||||
// ALREADY_PAID), and an unbounded permit. Those two tests come
|
// first — the sentinel is not a bigint and cannot be compared with
|
||||||
// first — the sentinels are not bigints and cannot be compared
|
// one.
|
||||||
// with one.
|
|
||||||
let amount;
|
let amount;
|
||||||
if (inputAmount === OPEN_DELTA) {
|
if (inputAmount === OPEN_DELTA) {
|
||||||
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
amount = { raw: OPEN_DELTA_AMOUNT, display: OPEN_DELTA_AMOUNT };
|
||||||
} else if (inputAmount === ALREADY_PAID) {
|
|
||||||
amount = {
|
|
||||||
raw: ALREADY_PAID_AMOUNT,
|
|
||||||
display: ALREADY_PAID_AMOUNT,
|
|
||||||
};
|
|
||||||
} else if (inputAmount >= MAX_UINT160) {
|
} else if (inputAmount >= MAX_UINT160) {
|
||||||
amount = { raw: "Unlimited", display: "Unlimited" };
|
amount = { raw: "Unlimited", display: "Unlimited" };
|
||||||
} else if (hasV2ExactOut) {
|
} else if (hasV2ExactOut) {
|
||||||
|
|||||||
@@ -184,22 +184,6 @@ describe("decodeCalldata amount", () => {
|
|||||||
expect(line).not.toMatch(/0\.0000/);
|
expect(line).not.toMatch(/0\.0000/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A token added by hand carries whatever its decimals() returned, and a
|
|
||||||
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
|
|
||||||
// call undecoded rather than refused
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
|
||||||
test("a token reporting more than 80 decimals shows base units", () => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: NOVEL_TOKEN, symbol: "NOVEL", decimals: 81 },
|
|
||||||
];
|
|
||||||
expect(
|
|
||||||
amountLine(transferData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN),
|
|
||||||
).toBe("5000000000 base units (decimals unknown)");
|
|
||||||
expect(amountLine(approveData(FIVE_THOUSAND_AT_SIX), NOVEL_TOKEN)).toBe(
|
|
||||||
"5000000000 base units (decimals unknown)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an unbounded allowance is still named, with or without a scale", () => {
|
test("an unbounded allowance is still named, with or without a scale", () => {
|
||||||
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
expect(amountLine(approveData(MAX_UINT256), NOVEL_TOKEN)).toBe(
|
||||||
"Unlimited",
|
"Unlimited",
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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(
|
const approved = await prepareApprovalTx(
|
||||||
providerWith(),
|
providerWith(),
|
||||||
signer.address,
|
signer.address,
|
||||||
@@ -133,7 +133,7 @@ describe("prepareApprovalTx", () => {
|
|||||||
maxPriorityFeePerGas: "0x3b9aca00",
|
maxPriorityFeePerGas: "0x3b9aca00",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(approved.nonce).toBe("0x7");
|
expect(approved.nonce).toBe("0x2");
|
||||||
expect(approved.gasLimit).toBe("0x30d40");
|
expect(approved.gasLimit).toBe("0x30d40");
|
||||||
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
expect(approved.maxFeePerGas).toBe("0x12a05f200");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const other = new Wallet(OTHER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const ORIGIN = "https://dapp.example";
|
const ORIGIN = "https://dapp.example";
|
||||||
|
const HOSTNAME = "dapp.example";
|
||||||
// A page the wallet has never been connected to, whose requests are refused.
|
// A page the wallet has never been connected to, whose requests are refused.
|
||||||
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
const UNCONNECTED_ORIGIN = "https://stranger.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
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.
|
// "Hello AutistMask" as the hex string a dApp passes to personal_sign.
|
||||||
const MESSAGE = "0x48656c6c6f204175746973744d61736b";
|
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 transaction the background populates and the approval screen displays.
|
||||||
// The nonce is a parameter because the duplicate case turns on two artifacts
|
// The nonce is a parameter because the duplicate case turns on two artifacts
|
||||||
// differing in a field the dApp fixed nothing for.
|
// differing in a field the dApp fixed nothing for.
|
||||||
@@ -214,7 +199,7 @@ function loadBackground(options) {
|
|||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: "https://rpc.invalid",
|
rpcUrl: "https://rpc.invalid",
|
||||||
activeAddress: signer.address,
|
activeAddress: signer.address,
|
||||||
allowedSites: { [signer.address]: [ORIGIN] },
|
allowedSites: { [signer.address]: [HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +230,6 @@ function loadBackground(options) {
|
|||||||
// raised through action.openPopup() opens no window at all, so this is
|
// raised through action.openPopup() opens no window at all, so this is
|
||||||
// the only place its id appears.
|
// the only place its id appears.
|
||||||
const actionPopups = [];
|
const actionPopups = [];
|
||||||
const openPopup = jest.fn(() => Promise.resolve());
|
|
||||||
|
|
||||||
global.chrome = {
|
global.chrome = {
|
||||||
storage,
|
storage,
|
||||||
@@ -300,7 +284,7 @@ function loadBackground(options) {
|
|||||||
// popup: no window is created, so windows.onRemoved can never
|
// popup: no window is created, so windows.onRemoved can never
|
||||||
// fire for it and the port disconnect is the only close signal
|
// fire for it and the port disconnect is the only close signal
|
||||||
// that exists.
|
// 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
|
// The same for a message-signing approval, which pins the signing address
|
||||||
// at approval time in exactly the same way.
|
// at approval time in exactly the same way.
|
||||||
function requestSign(from, origin) {
|
function requestSign(from) {
|
||||||
let rpcResult = null;
|
let rpcResult = null;
|
||||||
messageListener(
|
messageListener(
|
||||||
{
|
{
|
||||||
@@ -355,7 +339,7 @@ function loadBackground(options) {
|
|||||||
method: "personal_sign",
|
method: "personal_sign",
|
||||||
params: [MESSAGE, from || signer.address],
|
params: [MESSAGE, from || signer.address],
|
||||||
},
|
},
|
||||||
{ origin: origin || ORIGIN },
|
{ origin: ORIGIN },
|
||||||
(r) => {
|
(r) => {
|
||||||
rpcResult = 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
|
// A dApp asking to connect. The origin defaults to one the persisted
|
||||||
// state has never allowed, so the request really does raise a prompt
|
// state has never allowed, so the request really does raise a prompt
|
||||||
// instead of being answered from allowedSites.
|
// instead of being answered from allowedSites.
|
||||||
@@ -461,15 +427,12 @@ function loadBackground(options) {
|
|||||||
send,
|
send,
|
||||||
requestTx,
|
requestTx,
|
||||||
requestSign,
|
requestSign,
|
||||||
requestTypedData,
|
|
||||||
requestSite,
|
requestSite,
|
||||||
connectApproval,
|
connectApproval,
|
||||||
closeWindow,
|
closeWindow,
|
||||||
broadcastTransaction,
|
broadcastTransaction,
|
||||||
created,
|
created,
|
||||||
removed,
|
removed,
|
||||||
actionPopups,
|
|
||||||
openPopup,
|
|
||||||
storage,
|
storage,
|
||||||
// The user switching account in the toolbar popup, as the background
|
// The user switching account in the toolbar popup, as the background
|
||||||
// sees it: the persisted active address changes underneath a pending
|
// 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
|
// 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
|
// 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
|
// 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
|
// Settings lists the sites allowed without "Remember", which only the
|
||||||
// background holds, and removing a site there, from either list, disconnects
|
// background holds, and removing a site there, from either list, disconnects
|
||||||
// it. These drive the real Settings view against the real background and
|
// it. These drive the real Settings view against the real background and
|
||||||
// click the [x] the user clicks.
|
// click the [x] the user clicks.
|
||||||
describe("removing a site in Settings disconnects it", () => {
|
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";
|
const FRESH_OTHER_PORT = "https://fresh.example:8443";
|
||||||
|
|
||||||
// A site list's container. Its [x] buttons, data attributes and all, are
|
// 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;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The origins a site list shows.
|
// The hostnames a site list shows.
|
||||||
function listed(list) {
|
function listed(list) {
|
||||||
return [...list.innerHTML.matchAll(/data-origin="([^"]*)"/g)].map(
|
return [...list.innerHTML.matchAll(/data-hostname="([^"]*)"/g)].map(
|
||||||
(match) => match[1],
|
(match) => match[1],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2570,10 +2259,10 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
allowed: element("settings-allowed-sites"),
|
allowed: element("settings-allowed-sites"),
|
||||||
connected: element("settings-connected-sites"),
|
connected: element("settings-connected-sites"),
|
||||||
// Click the [x] beside a site, and let what it sends run.
|
// Click the [x] beside a site, and let what it sends run.
|
||||||
remove: async (list, origin) => {
|
remove: async (list, hostname) => {
|
||||||
expect(listed(list)).toContain(origin);
|
expect(listed(list)).toContain(hostname);
|
||||||
const button = list.buttons.find(
|
const button = list.buttons.find(
|
||||||
(b) => b.dataset.origin === origin,
|
(b) => b.dataset.hostname === hostname,
|
||||||
);
|
);
|
||||||
await button.click();
|
await button.click();
|
||||||
await settle();
|
await settle();
|
||||||
@@ -2585,15 +2274,14 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
delete global.document;
|
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 });
|
const bg = loadBackground({ actionPopup: true });
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
await connect(bg, FRESH_ORIGIN, false);
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
|
||||||
|
|
||||||
const settings = await openSettings(bg);
|
const settings = await openSettings(bg);
|
||||||
|
|
||||||
expect(listed(settings.connected)).toEqual([FRESH_ORIGIN]);
|
expect(listed(settings.connected)).toEqual(["fresh.example"]);
|
||||||
expect(listed(settings.allowed)).toEqual([ORIGIN, FRESH_OTHER_PORT]);
|
expect(listed(settings.allowed)).toEqual([HOSTNAME]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("removing a site connected without Remember disconnects it and tells its tabs", async () => {
|
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([
|
cb([
|
||||||
{ id: 1, url: FRESH_ORIGIN + "/app" },
|
{ id: 1, url: FRESH_ORIGIN + "/app" },
|
||||||
{ id: 2, url: ORIGIN + "/app" },
|
{ id: 2, url: ORIGIN + "/app" },
|
||||||
{ id: 3, url: FRESH_OTHER_PORT + "/app" },
|
|
||||||
]),
|
]),
|
||||||
sendMessage: (tabId, msg, cb) => {
|
sendMessage: (tabId, msg, cb) => {
|
||||||
sentToTabs.push({ tabId, msg });
|
sentToTabs.push({ tabId, msg });
|
||||||
@@ -2614,7 +2301,7 @@ describe("removing a site in Settings disconnects it", () => {
|
|||||||
};
|
};
|
||||||
const settings = await openSettings(bg);
|
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(await siteAccounts(bg)).toEqual({ result: [] });
|
||||||
expect(sentToTabs).toEqual([
|
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:
|
// The same hostname can hold both kinds of connection: one origin allowed
|
||||||
// remembered for one, allowed without Remember for the other.
|
// without Remember, then another, on a different port, allowed with it.
|
||||||
test("removing a remembered site also ends its connection made without Remember", async () => {
|
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 });
|
const bg = loadBackground({ actionPopup: true });
|
||||||
await connect(bg, FRESH_ORIGIN, false);
|
await connect(bg, FRESH_ORIGIN, false);
|
||||||
await connect(bg, FRESH_OTHER_PORT, true);
|
await connect(bg, FRESH_OTHER_PORT, true);
|
||||||
const settings = await openSettings(bg);
|
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({
|
expect(await siteAccounts(bg, FRESH_OTHER_PORT)).toEqual({
|
||||||
result: [],
|
result: [],
|
||||||
});
|
});
|
||||||
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({
|
expect(await siteAccounts(bg, FRESH_ORIGIN)).toEqual({ result: [] });
|
||||||
result: [signer.address],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a page can neither remove a site nor list the connected ones", async () => {
|
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 page = { url: FRESH_ORIGIN + "/index.html" };
|
||||||
|
|
||||||
const remove = bg.send(
|
const remove = bg.send(
|
||||||
{ type: "AUTISTMASK_REMOVE_SITE", origin: FRESH_ORIGIN },
|
{ type: "AUTISTMASK_REMOVE_SITE", hostname: "fresh.example" },
|
||||||
page,
|
page,
|
||||||
);
|
);
|
||||||
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
|
const list = bg.send({ type: "AUTISTMASK_GET_CONNECTED_SITES" }, page);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const signer = new Wallet(SIGNER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -80,7 +81,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: net.defaultRpcUrl,
|
rpcUrl: net.defaultRpcUrl,
|
||||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
lastBalanceRefresh: 0,
|
lastBalanceRefresh: 0,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
|||||||
|
|
||||||
// The site the persisted state has connected, and one it has never heard of.
|
// The site the persisted state has connected, and one it has never heard of.
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const STRANGER_ORIGIN = "https://stranger.example";
|
const STRANGER_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -85,7 +86,7 @@ function loadBackground() {
|
|||||||
tokenHolderCache: {},
|
tokenHolderCache: {},
|
||||||
fraudContracts: [],
|
fraudContracts: [],
|
||||||
activeAddress: ADDRESS,
|
activeAddress: ADDRESS,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
};
|
};
|
||||||
const storage = makeStorageStub({ autistmask: persisted });
|
const storage = makeStorageStub({ autistmask: persisted });
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const { networkById } = require("../src/shared/networks");
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const UNKNOWN_ORIGIN = "https://stranger.example";
|
const UNKNOWN_ORIGIN = "https://stranger.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
@@ -40,7 +41,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: networkById(networkId).defaultRpcUrl,
|
rpcUrl: networkById(networkId).defaultRpcUrl,
|
||||||
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
blockscoutUrl: networkById(networkId).defaultBlockscoutUrl,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const { makeStorageStub } = require("./support/storageStub");
|
|||||||
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDRESS = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
|
|
||||||
const MAINNET = networkById("mainnet");
|
const MAINNET = networkById("mainnet");
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
@@ -49,7 +50,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: CUSTOM_RPC,
|
rpcUrl: CUSTOM_RPC,
|
||||||
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
blockscoutUrl: CUSTOM_BLOCKSCOUT,
|
||||||
allowedSites: { [ADDRESS]: [CONNECTED_ORIGIN] },
|
allowedSites: { [ADDRESS]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
trackedTokens: [{ address: TOKEN, symbol: "DAI", decimals: 18 }],
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
@@ -167,7 +168,7 @@ describe("a chain switch on a worker that never loaded state", () => {
|
|||||||
expect(after.wallets).toEqual(walletFixture());
|
expect(after.wallets).toEqual(walletFixture());
|
||||||
expect(after.hasWallet).toBe(true);
|
expect(after.hasWallet).toBe(true);
|
||||||
expect(after.activeAddress).toBe(ADDRESS);
|
expect(after.activeAddress).toBe(ADDRESS);
|
||||||
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_ORIGIN] });
|
expect(after.allowedSites).toEqual({ [ADDRESS]: [CONNECTED_HOSTNAME] });
|
||||||
expect(after.trackedTokens).toEqual([
|
expect(after.trackedTokens).toEqual([
|
||||||
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
{ address: TOKEN, symbol: "DAI", decimals: 18 },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const signer = new Wallet(SIGNER_KEY);
|
|||||||
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const RECIPIENT = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
|
|
||||||
const CONNECTED_ORIGIN = "https://dapp.example";
|
const CONNECTED_ORIGIN = "https://dapp.example";
|
||||||
|
const CONNECTED_HOSTNAME = "dapp.example";
|
||||||
const EXT_URL = "chrome-extension://autistmask/";
|
const EXT_URL = "chrome-extension://autistmask/";
|
||||||
|
|
||||||
const SEPOLIA = networkById("sepolia");
|
const SEPOLIA = networkById("sepolia");
|
||||||
@@ -66,7 +67,7 @@ function storedProfile(networkId) {
|
|||||||
networkId,
|
networkId,
|
||||||
rpcUrl: net.defaultRpcUrl,
|
rpcUrl: net.defaultRpcUrl,
|
||||||
blockscoutUrl: net.defaultBlockscoutUrl,
|
blockscoutUrl: net.defaultBlockscoutUrl,
|
||||||
allowedSites: { [signer.address]: [CONNECTED_ORIGIN] },
|
allowedSites: { [signer.address]: [CONNECTED_HOSTNAME] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ async function openTxApproval(to, data) {
|
|||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||||
reply({
|
reply({
|
||||||
type: "tx",
|
type: "tx",
|
||||||
origin: "https://dapp.example",
|
hostname: "dapp.example",
|
||||||
isPhishingDomain: false,
|
isPhishingDomain: false,
|
||||||
approvedFrom: FROM,
|
approvedFrom: FROM,
|
||||||
approvedTx: {
|
approvedTx: {
|
||||||
|
|||||||
@@ -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",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -140,14 +140,8 @@ function load() {
|
|||||||
state.selectedWallet = 0;
|
state.selectedWallet = 0;
|
||||||
state.selectedAddress = 0;
|
state.selectedAddress = 0;
|
||||||
state.activeAddress = A0;
|
state.activeAddress = A0;
|
||||||
state.allowedSites = {
|
state.allowedSites = { [A0]: ["a.example"], [B0]: ["b.example"] };
|
||||||
[A0]: ["https://a.example"],
|
state.deniedSites = { [B0]: ["c.example"], [C0]: ["d.example"] };
|
||||||
[B0]: ["https://b.example"],
|
|
||||||
};
|
|
||||||
state.deniedSites = {
|
|
||||||
[B0]: ["https://c.example"],
|
|
||||||
[C0]: ["https://d.example"],
|
|
||||||
};
|
|
||||||
state.viewStack = ["main", "settings"];
|
state.viewStack = ["main", "settings"];
|
||||||
state.currentView = "settings";
|
state.currentView = "settings";
|
||||||
|
|
||||||
@@ -394,8 +388,8 @@ describe("deleting without the password", () => {
|
|||||||
await click("btn-delete-wallet-lost-confirm");
|
await click("btn-delete-wallet-lost-confirm");
|
||||||
|
|
||||||
const saved = (await storage.get("autistmask")).autistmask;
|
const saved = (await storage.get("autistmask")).autistmask;
|
||||||
expect(saved.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
expect(saved.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||||
expect(saved.deniedSites).toEqual({ [C0]: ["https://d.example"] });
|
expect(saved.deniedSites).toEqual({ [C0]: ["d.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The route shares finishDelete() with the password route, so the
|
// 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 () => {
|
test("deleting the last wallet lands on Welcome with nothing left", async () => {
|
||||||
const { deleteWallet, state, storage } = load();
|
const { deleteWallet, state, storage } = load();
|
||||||
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
state.wallets = [wallet("Wallet 1", "secret-one", [A0])];
|
||||||
state.allowedSites = { [A0]: ["https://a.example"] };
|
state.allowedSites = { [A0]: ["a.example"] };
|
||||||
state.deniedSites = {};
|
state.deniedSites = {};
|
||||||
|
|
||||||
await openLostPassword(deleteWallet, 0);
|
await openLostPassword(deleteWallet, 0);
|
||||||
|
|||||||
@@ -438,10 +438,11 @@ step(
|
|||||||
await d.switchToWindow(popup);
|
await d.switchToWindow(popup);
|
||||||
await d.waitVisible("#view-approve-site");
|
await d.waitVisible("#view-approve-site");
|
||||||
|
|
||||||
const origin = await d.text("#approve-origin");
|
const hostname = await d.text("#approve-hostname");
|
||||||
assert(
|
assert(
|
||||||
origin === env.server.origin,
|
hostname === "127.0.0.1",
|
||||||
"the site prompt names the wrong origin: " + JSON.stringify(origin),
|
"the site prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(hostname),
|
||||||
);
|
);
|
||||||
const shown = await d.text("#approve-address");
|
const shown = await d.text("#approve-address");
|
||||||
assert(
|
assert(
|
||||||
@@ -493,16 +494,16 @@ step(
|
|||||||
|
|
||||||
const screen = await d.execute(
|
const screen = await d.execute(
|
||||||
`return {
|
`return {
|
||||||
origin: document.getElementById("approve-sign-origin").textContent,
|
hostname: document.getElementById("approve-sign-hostname").textContent,
|
||||||
type: document.getElementById("approve-sign-type").textContent,
|
type: document.getElementById("approve-sign-type").textContent,
|
||||||
message: document.getElementById("approve-sign-message").textContent,
|
message: document.getElementById("approve-sign-message").textContent,
|
||||||
from: document.getElementById("approve-sign-from").textContent,
|
from: document.getElementById("approve-sign-from").textContent,
|
||||||
};`,
|
};`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.origin === env.server.origin,
|
screen.hostname === "127.0.0.1",
|
||||||
"the sign prompt names the wrong origin: " +
|
"the sign prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.type === "Personal message",
|
screen.type === "Personal message",
|
||||||
@@ -570,7 +571,7 @@ step(
|
|||||||
|
|
||||||
const screen = await d.execute(
|
const screen = await d.execute(
|
||||||
`return {
|
`return {
|
||||||
origin: document.getElementById("approve-tx-origin").textContent,
|
hostname: document.getElementById("approve-tx-hostname").textContent,
|
||||||
from: document.getElementById("approve-tx-from").textContent,
|
from: document.getElementById("approve-tx-from").textContent,
|
||||||
to: document.getElementById("approve-tx-to").textContent,
|
to: document.getElementById("approve-tx-to").textContent,
|
||||||
value: document.getElementById("approve-tx-value").textContent,
|
value: document.getElementById("approve-tx-value").textContent,
|
||||||
@@ -581,9 +582,9 @@ step(
|
|||||||
};`,
|
};`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.origin === env.server.origin,
|
screen.hostname === "127.0.0.1",
|
||||||
"the transaction prompt names the wrong origin: " +
|
"the transaction prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
screen.from.toLowerCase().includes(env.address.toLowerCase()),
|
||||||
|
|||||||
+19
-74
@@ -35,7 +35,6 @@ const {
|
|||||||
const {
|
const {
|
||||||
DAPP_ORIGIN,
|
DAPP_ORIGIN,
|
||||||
DAPP_URL,
|
DAPP_URL,
|
||||||
PHISHING_DAPP_ORIGIN,
|
|
||||||
PHISHING_DAPP_URL,
|
PHISHING_DAPP_URL,
|
||||||
FEE_ESTIMATE_WEI,
|
FEE_ESTIMATE_WEI,
|
||||||
FEE_RESERVE_WEI,
|
FEE_RESERVE_WEI,
|
||||||
@@ -2472,6 +2471,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
|
// dApp, with real funds, against a real network. The RPC is stubbed
|
||||||
// throughout. That pass stays on the human list before 1.0.0.
|
// 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 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 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
|
// the approval screen as the decoded text, which is what the user is agreeing
|
||||||
@@ -3015,10 +3016,11 @@ test("eth_requestAccounts rejected at the prompt returns a rejection (#183)", as
|
|||||||
try {
|
try {
|
||||||
await visible(popup, "#view-approve-site");
|
await visible(popup, "#view-approve-site");
|
||||||
|
|
||||||
const origin = await popup.locator("#approve-origin").innerText();
|
const hostname = await popup.locator("#approve-hostname").innerText();
|
||||||
assert(
|
assert(
|
||||||
origin === DAPP_ORIGIN,
|
hostname === DAPP_HOSTNAME,
|
||||||
"the site prompt names the wrong origin: " + JSON.stringify(origin),
|
"the site prompt names the wrong origin: " +
|
||||||
|
JSON.stringify(hostname),
|
||||||
);
|
);
|
||||||
|
|
||||||
// The control for the phishing test below: this origin is not on the
|
// The control for the phishing test below: this origin is not on the
|
||||||
@@ -3099,6 +3101,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 —
|
// 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.
|
// there is nothing left to stub, since the extension no longer fetches it.
|
||||||
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
|
const phishingDapp = await openDapp(env.ctx, PHISHING_DAPP_URL);
|
||||||
|
const hostname = new URL(PHISHING_DAPP_URL).hostname;
|
||||||
try {
|
try {
|
||||||
await reserveApprovalTab(env);
|
await reserveApprovalTab(env);
|
||||||
await startRequest(
|
await startRequest(
|
||||||
@@ -3111,15 +3114,15 @@ test("a connect request from a blocklisted site is flagged (#219)", async (env)
|
|||||||
try {
|
try {
|
||||||
await visible(popup, "#view-approve-site");
|
await visible(popup, "#view-approve-site");
|
||||||
|
|
||||||
const shown = await popup.locator("#approve-origin").innerText();
|
const shown = await popup.locator("#approve-hostname").innerText();
|
||||||
assert(
|
assert(
|
||||||
shown === PHISHING_DAPP_ORIGIN,
|
shown === hostname,
|
||||||
"the site prompt names the wrong origin: " +
|
"the site prompt names the wrong origin: " +
|
||||||
JSON.stringify(shown),
|
JSON.stringify(shown),
|
||||||
);
|
);
|
||||||
|
|
||||||
await visible(popup, "#approve-site-phishing-warning");
|
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
|
// Not remembered: a remembered decision for this origin would
|
||||||
// outlive the test.
|
// outlive the test.
|
||||||
@@ -3149,15 +3152,15 @@ test("personal_sign signs, and the signature recovers to the address (#183)", as
|
|||||||
const boundary = await watchApprovalBoundary(popup, env);
|
const boundary = await watchApprovalBoundary(popup, env);
|
||||||
|
|
||||||
const screen = await popup.evaluate(() => ({
|
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,
|
type: document.getElementById("approve-sign-type").textContent,
|
||||||
message: document.getElementById("approve-sign-message").textContent,
|
message: document.getElementById("approve-sign-message").textContent,
|
||||||
from: document.getElementById("approve-sign-from").textContent,
|
from: document.getElementById("approve-sign-from").textContent,
|
||||||
}));
|
}));
|
||||||
assert(
|
assert(
|
||||||
screen.origin === DAPP_ORIGIN,
|
screen.hostname === DAPP_HOSTNAME,
|
||||||
"the sign prompt names the wrong origin: " +
|
"the sign prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.type === "Personal message",
|
screen.type === "Personal message",
|
||||||
@@ -3232,64 +3235,6 @@ test("personal_sign rejected returns a rejection to the page (#183)", async (env
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A right-to-left character must not move the characters around it: U+05C3
|
|
||||||
// between "5" and "00" would otherwise put "500" on screen before it. A
|
|
||||||
// paragraph separator (U+2029) before it, left in the text, would end the
|
|
||||||
// byte-order layout and bring that back
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403).
|
|
||||||
test("a personal message is laid out in the order of its bytes (#403)", async (env) => {
|
|
||||||
const rightToLeft = String.fromCodePoint(0x05c3);
|
|
||||||
const text =
|
|
||||||
"Sign in" +
|
|
||||||
String.fromCodePoint(0x2029) +
|
|
||||||
"Pay 5" +
|
|
||||||
rightToLeft +
|
|
||||||
"00 ETH";
|
|
||||||
await startRequest(env.dapp, "sign-bidi", "personal_sign", [
|
|
||||||
hexlify(toUtf8Bytes(text)),
|
|
||||||
env.expectedAddress,
|
|
||||||
]);
|
|
||||||
const popup = await waitForApprovalWindow(env.ctx);
|
|
||||||
await visible(popup, "#view-approve-sign");
|
|
||||||
|
|
||||||
// The text on screen, marks included, and the left edge of each of its
|
|
||||||
// characters, in byte order. A character the browser's fonts draw with
|
|
||||||
// no width shares its neighbour's edge.
|
|
||||||
const shown = await popup.evaluate(() => {
|
|
||||||
const message = document.getElementById("approve-sign-message");
|
|
||||||
const walker = document.createTreeWalker(message, NodeFilter.SHOW_TEXT);
|
|
||||||
const range = document.createRange();
|
|
||||||
let text = "";
|
|
||||||
const lefts = [];
|
|
||||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
||||||
for (let i = 0; i < node.length; i++) {
|
|
||||||
range.setStart(node, i);
|
|
||||||
range.setEnd(node, i + 1);
|
|
||||||
lefts.push(range.getBoundingClientRect().left);
|
|
||||||
}
|
|
||||||
text += node.data;
|
|
||||||
}
|
|
||||||
return { text, lefts };
|
|
||||||
});
|
|
||||||
await clickAndClose(popup, "#btn-reject-sign");
|
|
||||||
await assertUserRejection(
|
|
||||||
env.dapp,
|
|
||||||
"sign-bidi",
|
|
||||||
"the byte-order personal_sign rejection",
|
|
||||||
);
|
|
||||||
|
|
||||||
assert(
|
|
||||||
shown.text === "Sign inU+2029Pay 5" + rightToLeft + "00 ETH",
|
|
||||||
"the paragraph separator is not shown as a mark: " +
|
|
||||||
JSON.stringify(shown.text),
|
|
||||||
);
|
|
||||||
assert(
|
|
||||||
shown.lefts.every((left, i) => i === 0 || left >= shown.lefts[i - 1]),
|
|
||||||
"the personal message is not laid out in byte order: " +
|
|
||||||
JSON.stringify(shown.lefts),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env) => {
|
||||||
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
await startRequest(env.dapp, "typed", "eth_signTypedData_v4", [
|
||||||
env.expectedAddress,
|
env.expectedAddress,
|
||||||
@@ -3300,15 +3245,15 @@ test("eth_signTypedData_v4 signs, and the signature recovers (#183)", async (env
|
|||||||
const boundary = await watchApprovalBoundary(popup, env);
|
const boundary = await watchApprovalBoundary(popup, env);
|
||||||
|
|
||||||
const screen = await popup.evaluate(() => ({
|
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,
|
type: document.getElementById("approve-sign-type").textContent,
|
||||||
message: document.getElementById("approve-sign-message").innerText,
|
message: document.getElementById("approve-sign-message").innerText,
|
||||||
from: document.getElementById("approve-sign-from").textContent,
|
from: document.getElementById("approve-sign-from").textContent,
|
||||||
}));
|
}));
|
||||||
assert(
|
assert(
|
||||||
screen.origin === DAPP_ORIGIN,
|
screen.hostname === DAPP_HOSTNAME,
|
||||||
"the typed data prompt names the wrong origin: " +
|
"the typed data prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.type === "Typed data (EIP-712)",
|
screen.type === "Typed data (EIP-712)",
|
||||||
@@ -3406,7 +3351,7 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
|
|||||||
const boundary = await watchApprovalBoundary(popup, env);
|
const boundary = await watchApprovalBoundary(popup, env);
|
||||||
|
|
||||||
const screen = await popup.evaluate(() => ({
|
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,
|
from: document.getElementById("approve-tx-from").textContent,
|
||||||
to: document.getElementById("approve-tx-to").textContent,
|
to: document.getElementById("approve-tx-to").textContent,
|
||||||
value: document.getElementById("approve-tx-value").textContent,
|
value: document.getElementById("approve-tx-value").textContent,
|
||||||
@@ -3416,9 +3361,9 @@ test("eth_sendTransaction signs the approved transaction and broadcasts it (#183
|
|||||||
.classList.contains("hidden"),
|
.classList.contains("hidden"),
|
||||||
}));
|
}));
|
||||||
assert(
|
assert(
|
||||||
screen.origin === DAPP_ORIGIN,
|
screen.hostname === DAPP_HOSTNAME,
|
||||||
"the transaction prompt names the wrong origin: " +
|
"the transaction prompt names the wrong origin: " +
|
||||||
JSON.stringify(screen.origin),
|
JSON.stringify(screen.hostname),
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
|
screen.from.toLowerCase().includes(env.expectedAddress.toLowerCase()),
|
||||||
|
|||||||
@@ -59,32 +59,24 @@ describe("the floor under allowedSites and deniedSites", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`an ${field} entry whose value is not an origin list is dropped`, () => {
|
test(`an ${field} entry whose value is not a hostname list is dropped`, () => {
|
||||||
for (const bad of [
|
for (const bad of ["dapp.example", 42, null, { a: 1 }, true]) {
|
||||||
"https://dapp.example",
|
|
||||||
42,
|
|
||||||
null,
|
|
||||||
{ a: 1 },
|
|
||||||
true,
|
|
||||||
]) {
|
|
||||||
expect(
|
expect(
|
||||||
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
normalizePersisted({ [field]: { [ADDRESS]: bad } })[field],
|
||||||
).toEqual({});
|
).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(
|
expect(
|
||||||
normalizePersisted({
|
normalizePersisted({
|
||||||
[field]: {
|
[field]: { [ADDRESS]: [42, null, "dapp.example", {}] },
|
||||||
[ADDRESS]: [42, null, "https://dapp.example", {}],
|
|
||||||
},
|
|
||||||
})[field],
|
})[field],
|
||||||
).toEqual({ [ADDRESS]: ["https://dapp.example"] });
|
).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`a real ${field} map survives, copied not shared`, () => {
|
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);
|
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`, () => {
|
test(`a good ${field} entry beside a malformed one survives`, () => {
|
||||||
const out = normalizePersisted({
|
const out = normalizePersisted({
|
||||||
[field]: {
|
[field]: { [ADDRESS]: ["dapp.example"], [TOKEN_ADDRESS]: 42 },
|
||||||
[ADDRESS]: ["https://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`, () => {
|
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
|
// saveState()'s merge hands to the prototype setter on the next
|
||||||
// write.
|
// write.
|
||||||
const saved = JSON.parse(
|
const saved = JSON.parse(
|
||||||
'{"' + field + '":{"__proto__":["https://evil.invalid"]}}',
|
'{"' + field + '":{"__proto__":["evil.invalid"]}}',
|
||||||
);
|
);
|
||||||
|
|
||||||
const out = normalizePersisted(saved);
|
const out = normalizePersisted(saved);
|
||||||
@@ -208,7 +197,7 @@ describe("a malformed allowedSites entry", () => {
|
|||||||
const MALFORMED = [
|
const MALFORMED = [
|
||||||
{ name: "a string", value: "notalist" },
|
{ name: "a string", value: "notalist" },
|
||||||
{ name: "a number", value: 42 },
|
{ 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) {
|
for (const { name, value } of MALFORMED) {
|
||||||
@@ -242,7 +231,7 @@ describe("a malformed allowedSites entry", () => {
|
|||||||
const env = await bootPopup(
|
const env = await bootPopup(
|
||||||
unversionedValidProfile({
|
unversionedValidProfile({
|
||||||
allowedSites: {
|
allowedSites: {
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
[ADDRESS]: ["dapp.example"],
|
||||||
[TOKEN_ADDRESS]: "notalist",
|
[TOKEN_ADDRESS]: "notalist",
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -250,7 +239,7 @@ describe("a malformed allowedSites entry", () => {
|
|||||||
|
|
||||||
expect(env.pageErrors).toEqual([]);
|
expect(env.pageErrors).toEqual([]);
|
||||||
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
expect(env.storage.read("autistmask").allowedSites).toEqual({
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
[ADDRESS]: ["dapp.example"],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ const CONTRACT = [
|
|||||||
[ADDRESS],
|
[ADDRESS],
|
||||||
{ [ADDRESS]: 42 },
|
{ [ADDRESS]: 42 },
|
||||||
{ [ADDRESS]: [42, null, {}] },
|
{ [ADDRESS]: [42, null, {}] },
|
||||||
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
|
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||||
],
|
],
|
||||||
holds: siteMapHolds,
|
holds: siteMapHolds,
|
||||||
},
|
},
|
||||||
@@ -177,7 +177,7 @@ const CONTRACT = [
|
|||||||
[ADDRESS],
|
[ADDRESS],
|
||||||
{ [ADDRESS]: 42 },
|
{ [ADDRESS]: 42 },
|
||||||
{ [ADDRESS]: [42, null, {}] },
|
{ [ADDRESS]: [42, null, {}] },
|
||||||
JSON.parse('{"__proto__":["https://evil.invalid"]}'),
|
JSON.parse('{"__proto__":["evil.invalid"]}'),
|
||||||
],
|
],
|
||||||
holds: siteMapHolds,
|
holds: siteMapHolds,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,229 +0,0 @@
|
|||||||
// The signature prompt shows a personal message as the bytes that are signed
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/403): the raw data in hex, the
|
|
||||||
// text it decodes to with control characters, line and paragraph separators
|
|
||||||
// and characters that paint nothing marked rather than obeyed, markup shown as
|
|
||||||
// text, laid out in byte order, and a message that is not hex as plain text
|
|
||||||
// that cannot be signed.
|
|
||||||
//
|
|
||||||
// Driven against a minimal DOM stub in the shape
|
|
||||||
// tests/approvalOrigin.test.js uses. That the layout keeps right-to-left
|
|
||||||
// characters in byte order needs a real browser: tests/e2e/run.js checks it.
|
|
||||||
|
|
||||||
globalThis.chrome = {
|
|
||||||
storage: { local: { get: async () => ({}), set: async () => {} } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { hexlify, toUtf8Bytes } = require("ethers");
|
|
||||||
const { state } = require("../src/shared/state");
|
|
||||||
const approval = require("../src/popup/views/approval");
|
|
||||||
|
|
||||||
const FROM = "0x0000000000000000000000000000000000000a11";
|
|
||||||
|
|
||||||
// Built from their code points so that this file holds none of them.
|
|
||||||
const RIGHT_TO_LEFT_OVERRIDE = String.fromCodePoint(0x202e);
|
|
||||||
const POP_DIRECTIONAL_FORMATTING = String.fromCodePoint(0x202c);
|
|
||||||
const ZERO_WIDTH_SPACE = String.fromCodePoint(0x200b);
|
|
||||||
const VARIATION_SELECTOR_1 = String.fromCodePoint(0xfe00);
|
|
||||||
const VARIATION_SELECTOR_17 = String.fromCodePoint(0xe0100);
|
|
||||||
const HANGUL_FILLER = String.fromCodePoint(0x3164);
|
|
||||||
const LINE_SEPARATOR = String.fromCodePoint(0x2028);
|
|
||||||
const PARAGRAPH_SEPARATOR = String.fromCodePoint(0x2029);
|
|
||||||
|
|
||||||
function makeElement(id) {
|
|
||||||
const classes = new Set();
|
|
||||||
return {
|
|
||||||
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: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDocument() {
|
|
||||||
const els = new Map();
|
|
||||||
return {
|
|
||||||
getElementById(id) {
|
|
||||||
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 signature prompt for a personal_sign of `message`, the way the
|
|
||||||
// popup does: it asks the background for the approval and show() draws it.
|
|
||||||
async function openPersonalSign(message) {
|
|
||||||
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({
|
|
||||||
type: "sign",
|
|
||||||
origin: "https://dapp.example",
|
|
||||||
isPhishingDomain: false,
|
|
||||||
approvedFrom: FROM,
|
|
||||||
signParams: { method: "personal_sign", message, from: FROM },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
|
||||||
approval.init({});
|
|
||||||
await approval.show(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The message box's markup as the text a reader sees: tags dropped.
|
|
||||||
function shownMessage() {
|
|
||||||
return node("approve-sign-message").innerHTML.replace(/<[^>]*>/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
state.wallets = [];
|
|
||||||
state.activeAddress = FROM;
|
|
||||||
state.viewData = {};
|
|
||||||
state.viewStack = [];
|
|
||||||
state.currentView = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a right-to-left override is marked, so the text reads in byte order", async () => {
|
|
||||||
// Obeyed, the override shows "0001" as "1000".
|
|
||||||
const text =
|
|
||||||
"Pay " +
|
|
||||||
RIGHT_TO_LEFT_OVERRIDE +
|
|
||||||
"0001" +
|
|
||||||
POP_DIRECTIONAL_FORMATTING +
|
|
||||||
" ETH";
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes(text)));
|
|
||||||
const html = node("approve-sign-message").innerHTML;
|
|
||||||
expect(html).not.toContain(RIGHT_TO_LEFT_OVERRIDE);
|
|
||||||
expect(html).not.toContain(POP_DIRECTIONAL_FORMATTING);
|
|
||||||
expect(shownMessage()).toBe("Pay U+202E0001U+202C ETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a zero-width character is marked", async () => {
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(toUtf8Bytes("pay" + ZERO_WIDTH_SPACE + "pal.com")),
|
|
||||||
);
|
|
||||||
expect(node("approve-sign-message").innerHTML).not.toContain(
|
|
||||||
ZERO_WIDTH_SPACE,
|
|
||||||
);
|
|
||||||
expect(shownMessage()).toBe("payU+200Bpal.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("variation selectors and a Hangul filler are marked", async () => {
|
|
||||||
// Each paints nothing, so a page could hide bytes after "Sign in".
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(
|
|
||||||
toUtf8Bytes(
|
|
||||||
"Sign in" +
|
|
||||||
VARIATION_SELECTOR_1 +
|
|
||||||
VARIATION_SELECTOR_17 +
|
|
||||||
HANGUL_FILLER,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
expect(shownMessage()).toBe("Sign inU+FE00U+E0100U+3164");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the message is laid out in byte order", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("Hello")));
|
|
||||||
expect(
|
|
||||||
node("approve-sign-message").classList.contains("am-byte-order"),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a control character other than a line feed is marked", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("a\u0000b\tc")));
|
|
||||||
expect(shownMessage()).toBe("aU+0000bU+0009c");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("line and paragraph separators are marked", async () => {
|
|
||||||
// Left in the text, a paragraph separator would end the byte-order
|
|
||||||
// layout for everything after it.
|
|
||||||
await openPersonalSign(
|
|
||||||
hexlify(toUtf8Bytes("a" + LINE_SEPARATOR + "b" + PARAGRAPH_SEPARATOR)),
|
|
||||||
);
|
|
||||||
const html = node("approve-sign-message").innerHTML;
|
|
||||||
expect(html).not.toContain(LINE_SEPARATOR);
|
|
||||||
expect(html).not.toContain(PARAGRAPH_SEPARATOR);
|
|
||||||
expect(shownMessage()).toBe("aU+2028bU+2029");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a line feed is shown as a line break", async () => {
|
|
||||||
await openPersonalSign(hexlify(toUtf8Bytes("Sign in\nNonce: 7")));
|
|
||||||
expect(node("approve-sign-message").innerHTML).toBe("Sign in<br>Nonce: 7");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The message box is written as HTML, so a site's markup has to arrive there
|
|
||||||
// escaped, as the text it is.
|
|
||||||
const MARKUP = "<b>x</b><img src=x onerror=alert(1)>";
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["a hex message", hexlify(toUtf8Bytes(MARKUP))],
|
|
||||||
["a message that is not hex", MARKUP],
|
|
||||||
])("markup in %s is shown as text, not as markup", async (_, message) => {
|
|
||||||
await openPersonalSign(message);
|
|
||||||
expect(node("approve-sign-message").innerHTML).toBe(
|
|
||||||
"<b>x</b><img src=x onerror=alert(1)>",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the raw hex is shown alongside the text", async () => {
|
|
||||||
await openPersonalSign("0x48656c6c6f");
|
|
||||||
expect(shownMessage()).toBe("Hello");
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0x48656c6c6f");
|
|
||||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hex with an uppercase 0X is read as hex, as signing reads it", async () => {
|
|
||||||
await openPersonalSign("0X48656C6C6F");
|
|
||||||
expect(shownMessage()).toBe("Hello");
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0X48656C6C6F");
|
|
||||||
expect(node("btn-approve-sign").disabled).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bytes that are not text are shown only as hex", async () => {
|
|
||||||
await openPersonalSign("0xff00");
|
|
||||||
expect(node("approve-sign-message").textContent).toBe(
|
|
||||||
"This message is not text.",
|
|
||||||
);
|
|
||||||
expect(node("approve-sign-hex").textContent).toBe("0xff00");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a message that is not hex is shown as text and cannot be signed", async () => {
|
|
||||||
await openPersonalSign("Hello world");
|
|
||||||
expect(shownMessage()).toBe("Hello world");
|
|
||||||
expect(node("approve-sign-error").textContent).toBe(
|
|
||||||
"This message is plain text, not hex, so it cannot be signed.",
|
|
||||||
);
|
|
||||||
expect(node("btn-approve-sign").disabled).toBe(true);
|
|
||||||
expect(node("approve-sign-hex-section").classList.contains("hidden")).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -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:");
|
|
||||||
});
|
|
||||||
@@ -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.",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -66,7 +66,6 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
status: 200,
|
status: 200,
|
||||||
json: async () => mockExplorer.items,
|
json: async () => mockExplorer.items,
|
||||||
})),
|
})),
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
setRuntimeDebug: () => {},
|
||||||
isDebug: () => false,
|
isDebug: () => false,
|
||||||
}));
|
}));
|
||||||
@@ -233,7 +232,7 @@ async function confirmSend(amount, token = "ETH") {
|
|||||||
async function approveTxWithFeePerGas(maxFeePerGas) {
|
async function approveTxWithFeePerGas(maxFeePerGas) {
|
||||||
approvalDetails = {
|
approvalDetails = {
|
||||||
type: "tx",
|
type: "tx",
|
||||||
origin: "https://dapp.example",
|
hostname: "dapp.example",
|
||||||
approvedFrom: HOLDER,
|
approvedFrom: HOLDER,
|
||||||
approvedTx: {
|
approvedTx: {
|
||||||
to: RECIPIENT,
|
to: RECIPIENT,
|
||||||
|
|||||||
@@ -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
@@ -270,32 +270,31 @@ describe("background refresh racing a wallet deleted on another page", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// allowedSites/deniedSites: { [address]: [origin, ...] }. Mutated in place
|
// allowedSites/deniedSites: { [address]: [hostname, ...] }. Mutated in place
|
||||||
// from two different contexts — rememberSiteChoice() in
|
// from two different contexts — src/background/index.js:592-599 pushes a
|
||||||
// src/background/index.js pushes a newly approved origin onto
|
// newly approved hostname onto state.allowedSites[activeAddress], and the
|
||||||
// state.allowedSites[activeAddress], and the Settings "revoke" button
|
// Settings "revoke" button (src/popup/views/settings.js:55-68) filters a
|
||||||
// (forgetOrigin() in src/popup/views/settings.js) filters an origin out of
|
// hostname out of state[key][addr] in place, deleting the address key
|
||||||
// state[key][addr] in place, deleting the address key entirely once its list
|
// entirely once its list is empty — the exact membership-vs-whole-field
|
||||||
// is empty — the exact membership-vs-whole-field pattern that made the
|
// pattern that made the whole-field `wallets` diff unsafe, on a
|
||||||
// whole-field `wallets` diff unsafe, on a security-relevant field: a stale
|
// security-relevant field: a stale whole-field save here can resurrect a
|
||||||
// whole-field save here can resurrect a revoked permission or wipe a freshly
|
// revoked permission or wipe a freshly granted one.
|
||||||
// granted one.
|
|
||||||
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
const ADDR1 = "0x66133E8ea0f5D1d612D2502a968757D1048c214a";
|
||||||
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
const ADDR2 = "0xdAC17F958D2ee523a2206206994597C13D831ec7";
|
||||||
|
|
||||||
function approveSite(pageState, address, origin) {
|
function approveSite(pageState, address, hostname) {
|
||||||
if (!pageState.allowedSites[address]) {
|
if (!pageState.allowedSites[address]) {
|
||||||
pageState.allowedSites[address] = [];
|
pageState.allowedSites[address] = [];
|
||||||
}
|
}
|
||||||
if (!pageState.allowedSites[address].includes(origin)) {
|
if (!pageState.allowedSites[address].includes(hostname)) {
|
||||||
pageState.allowedSites[address].push(origin);
|
pageState.allowedSites[address].push(hostname);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function revokeSite(pageState, origin) {
|
function revokeSite(pageState, hostname) {
|
||||||
for (const addr of Object.keys(pageState.allowedSites)) {
|
for (const addr of Object.keys(pageState.allowedSites)) {
|
||||||
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
|
pageState.allowedSites[addr] = pageState.allowedSites[addr].filter(
|
||||||
(o) => o !== origin,
|
(h) => h !== hostname,
|
||||||
);
|
);
|
||||||
if (pageState.allowedSites[addr].length === 0) {
|
if (pageState.allowedSites[addr].length === 0) {
|
||||||
delete pageState.allowedSites[addr];
|
delete pageState.allowedSites[addr];
|
||||||
@@ -309,7 +308,7 @@ describe("a dApp approval racing a stale Settings page's later save", () => {
|
|||||||
await storage.set({
|
await storage.set({
|
||||||
autistmask: {
|
autistmask: {
|
||||||
wallets: [W1],
|
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();
|
await settings.state.loadState();
|
||||||
|
|
||||||
// A dApp approval window, opened later, approves a new site for a
|
// A dApp approval window, opened later, approves a new site for a
|
||||||
// different address and saves — the real sequence in
|
// different address and saves — the real sequence at
|
||||||
// rememberSiteChoice(), src/background/index.js.
|
// src/background/index.js:592-599.
|
||||||
const approval = loadPage(storage);
|
const approval = loadPage(storage);
|
||||||
await approval.state.loadState();
|
await approval.state.loadState();
|
||||||
approveSite(approval.state.state, ADDR1, "https://dapp.example");
|
approveSite(approval.state.state, ADDR1, "dapp.example");
|
||||||
await approval.state.saveState();
|
await approval.state.saveState();
|
||||||
expect(
|
expect(
|
||||||
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
||||||
).toEqual(["https://dapp.example"]);
|
).toEqual(["dapp.example"]);
|
||||||
|
|
||||||
// Settings revokes its own, unrelated site — the real sequence in
|
// Settings revokes its own, unrelated site — the real sequence at
|
||||||
// forgetOrigin(), src/popup/views/settings.js — and saves from state
|
// src/popup/views/settings.js:55-68 — and saves from state loaded
|
||||||
// loaded before the dApp approval ever happened.
|
// before the dApp approval ever happened.
|
||||||
revokeSite(settings.state.state, "https://other.example");
|
revokeSite(settings.state.state, "other.example");
|
||||||
await settings.state.saveState();
|
await settings.state.saveState();
|
||||||
|
|
||||||
const persisted = (await storage.get("autistmask")).autistmask;
|
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();
|
expect(persisted.allowedSites[ADDR2]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -347,7 +346,7 @@ describe("a revoked site permission against a stale page's later save", () => {
|
|||||||
await storage.set({
|
await storage.set({
|
||||||
autistmask: {
|
autistmask: {
|
||||||
wallets: [W1],
|
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);
|
const stale = loadPage(storage);
|
||||||
await stale.state.loadState();
|
await stale.state.loadState();
|
||||||
|
|
||||||
// Settings revokes it — forgetOrigin(), src/popup/views/settings.js —
|
// Settings revokes it — src/popup/views/settings.js:55-68 — from a
|
||||||
// from a second page.
|
// second page.
|
||||||
const settings = loadPage(storage);
|
const settings = loadPage(storage);
|
||||||
await settings.state.loadState();
|
await settings.state.loadState();
|
||||||
revokeSite(settings.state.state, "https://evil.example");
|
revokeSite(settings.state.state, "evil.example");
|
||||||
await settings.state.saveState();
|
await settings.state.saveState();
|
||||||
expect(
|
expect(
|
||||||
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
(await storage.get("autistmask")).autistmask.allowedSites[ADDR1],
|
||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
|
|
||||||
// The stale page, unaware of the revoke, approves an unrelated site
|
// The stale page, unaware of the revoke, approves an unrelated site
|
||||||
// for a different address and saves — rememberSiteChoice(),
|
// for a different address and saves — src/background/index.js:592-599.
|
||||||
// src/background/index.js.
|
approveSite(stale.state.state, ADDR2, "good.example");
|
||||||
approveSite(stale.state.state, ADDR2, "https://good.example");
|
|
||||||
await stale.state.saveState();
|
await stale.state.saveState();
|
||||||
|
|
||||||
const persisted = (await storage.get("autistmask")).autistmask;
|
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();
|
expect(persisted.allowedSites[ADDR1]).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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].encryptedSecret).toBe("encrypted-secret-1");
|
||||||
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
expect(stored.wallets[0].addresses[0].address).toBe(ADDRESS);
|
||||||
expect(stored.activeAddress).toBe(ADDRESS);
|
expect(stored.activeAddress).toBe(ADDRESS);
|
||||||
expect(stored.allowedSites).toEqual({
|
expect(stored.allowedSites).toEqual({ [ADDRESS]: ["dapp.example"] });
|
||||||
[ADDRESS]: ["https://dapp.example"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ function unversionedValidProfile(extra) {
|
|||||||
networkId: "mainnet",
|
networkId: "mainnet",
|
||||||
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
rpcUrl: "https://ethereum-rpc.publicnode.com",
|
||||||
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
blockscoutUrl: "https://eth.blockscout.com/api/v2",
|
||||||
allowedSites: { [ADDRESS]: ["https://dapp.example"] },
|
allowedSites: { [ADDRESS]: ["dapp.example"] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
trackedTokens: [],
|
trackedTokens: [],
|
||||||
theme: "system",
|
theme: "system",
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ jest.mock("../src/shared/log", () => ({
|
|||||||
errorf: () => {},
|
errorf: () => {},
|
||||||
},
|
},
|
||||||
debugFetch: jest.fn(),
|
debugFetch: jest.fn(),
|
||||||
urlOrigin: () => "",
|
|
||||||
setRuntimeDebug: () => {},
|
setRuntimeDebug: () => {},
|
||||||
isDebug: () => false,
|
isDebug: () => false,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// contract at signing time, with nothing comparing the two, so a token whose
|
// contract at signing time, with nothing comparing the two, so a token whose
|
||||||
// on-chain scale differed signed an amount that was never displayed.
|
// on-chain scale differed signed an amount that was never displayed.
|
||||||
|
|
||||||
const { formatUnits, parseUnits } = require("ethers");
|
const { parseUnits } = require("ethers");
|
||||||
const {
|
const {
|
||||||
displayedDecimals,
|
displayedDecimals,
|
||||||
transferAmountUnits,
|
transferAmountUnits,
|
||||||
@@ -25,17 +25,7 @@ describe("displayedDecimals", () => {
|
|||||||
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
|
expect(displayedDecimals(MAX_DECIMALS)).toBe(MAX_DECIMALS);
|
||||||
});
|
});
|
||||||
|
|
||||||
// decimals() is a uint8, but formatUnits() and parseUnits() stop at 80
|
test("refuses anything that is not a uint8", () => {
|
||||||
// places, so a larger scale cannot be shown or encoded
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
|
||||||
test("accepts exactly the scales the formatter accepts", () => {
|
|
||||||
expect(() => formatUnits(1n, MAX_DECIMALS)).not.toThrow();
|
|
||||||
expect(() => parseUnits("1", MAX_DECIMALS)).not.toThrow();
|
|
||||||
expect(() => formatUnits(1n, MAX_DECIMALS + 1)).toThrow();
|
|
||||||
expect(() => parseUnits("1", MAX_DECIMALS + 1)).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("refuses anything that is not a uint8 the formatter accepts", () => {
|
|
||||||
for (const bad of [
|
for (const bad of [
|
||||||
null,
|
null,
|
||||||
undefined,
|
undefined,
|
||||||
|
|||||||
@@ -471,7 +471,7 @@ async function openSignScreen(data) {
|
|||||||
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
if (msg.type !== "AUTISTMASK_GET_APPROVAL") return reply(null);
|
||||||
reply({
|
reply({
|
||||||
type: "sign",
|
type: "sign",
|
||||||
origin: "https://dapp.example",
|
hostname: "dapp.example",
|
||||||
isPhishingDomain: false,
|
isPhishingDomain: false,
|
||||||
approvedFrom: OWNER,
|
approvedFrom: OWNER,
|
||||||
signParams: request(data),
|
signParams: request(data),
|
||||||
|
|||||||
@@ -831,104 +831,6 @@ describe("uniswap decoder", () => {
|
|||||||
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
|
expect(detail(result, "Min. received").value).toBe("0.9900 USDC");
|
||||||
});
|
});
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router's V2
|
|
||||||
// exact-in reads an amountIn of zero as universal-router
|
|
||||||
// Constants.ALREADY_PAID: an earlier step sent the tokens to the pair, and
|
|
||||||
// the swap uses all of them. Against 375998b this read "0.0000 USDT".
|
|
||||||
test("a V2 exact-in already-paid amountIn is named, not printed as zero", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
"0x08",
|
|
||||||
[
|
|
||||||
encodeV2SwapExactIn(
|
|
||||||
USER_ADDR,
|
|
||||||
0n, // Constants.ALREADY_PAID
|
|
||||||
500000000000000n,
|
|
||||||
[USDT_ADDR, WETH_ADDR],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token In").value).toContain("USDT");
|
|
||||||
expect(detail(result, "Amount").value).toBe(
|
|
||||||
"Whatever an earlier step sent to the pair (V2 already paid)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Amount").rawValue).toBe(
|
|
||||||
"Whatever an earlier step sent to the pair (V2 already paid)",
|
|
||||||
);
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// https://git.eeqj.de/sneak/AutistMask/issues/415 — the router passes a
|
|
||||||
// BALANCE_CHECK_ERC20 whenever the balance is at least minBalance, so a
|
|
||||||
// zero one guarantees nothing. Against 375998b it replaced the swap's
|
|
||||||
// output side: Token Out = USDC, Min. received = "None (no minimum
|
|
||||||
// guaranteed)".
|
|
||||||
test("a zero balance check keeps the minimum a swap step stated", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
|
|
||||||
[
|
|
||||||
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
||||||
USDT_ADDR,
|
|
||||||
WETH_ADDR,
|
|
||||||
]),
|
|
||||||
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 0n),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("WETH");
|
|
||||||
expect(detail(result, "Min. received").value).toBe("0.0005 WETH");
|
|
||||||
});
|
|
||||||
|
|
||||||
// A nonzero balance check still replaces the output side, as before.
|
|
||||||
test("a nonzero balance check replaces the minimum a swap step stated", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x08, 0x0e]),
|
|
||||||
[
|
|
||||||
encodeV2SwapExactIn(USER_ADDR, 1000000n, 500000000000000n, [
|
|
||||||
USDT_ADDR,
|
|
||||||
WETH_ADDR,
|
|
||||||
]),
|
|
||||||
encodeBalanceCheck(USER_ADDR, USDC_ADDR, 2000000n),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDC");
|
|
||||||
expect(detail(result, "Min. received").value).toBe("2.0000 USDC");
|
|
||||||
});
|
|
||||||
|
|
||||||
// With no minimum stated before it, a zero balance check is what sets the
|
|
||||||
// output side, and it guarantees nothing.
|
|
||||||
test("a zero balance check with no earlier minimum states no minimum", () => {
|
|
||||||
const data = buildExecute(
|
|
||||||
solidityPacked(["uint8", "uint8"], [0x0b, 0x0e]),
|
|
||||||
[
|
|
||||||
encodeWrapEth(ROUTER_ADDR, 1000000000000000000n),
|
|
||||||
encodeBalanceCheck(USER_ADDR, USDT_ADDR, 0n),
|
|
||||||
],
|
|
||||||
9999999999n,
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = uniswap.decode(data, ROUTER_ADDR);
|
|
||||||
expect(result).not.toBeNull();
|
|
||||||
|
|
||||||
expect(detail(result, "Token Out").value).toContain("USDT");
|
|
||||||
expect(detail(result, "Min. received").value).toBe(
|
|
||||||
"None (no minimum guaranteed)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
|
// Pins what https://git.eeqj.de/sneak/AutistMask/pulls/356 changed without
|
||||||
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
|
// testing: a non-swap execute() carrying only PERMIT2_PERMIT names no
|
||||||
// output currency, so it says so and titles itself "Uniswap Swap" rather
|
// output currency, so it says so and titles itself "Uniswap Swap" rather
|
||||||
|
|||||||
@@ -126,19 +126,6 @@ describe("a swap of a token outside the bundled list", () => {
|
|||||||
test("a bundled token on the other side still formats", () => {
|
test("a bundled token on the other side still formats", () => {
|
||||||
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
|
expect(swapDetail(data(), "Min. received").value).toBe("0.5000 WETH");
|
||||||
});
|
});
|
||||||
|
|
||||||
// A token added by hand carries whatever its decimals() returned, and a
|
|
||||||
// uint8 reaches 255, but formatUnits() throws above 80. The throw left the
|
|
||||||
// whole swap undecoded rather than refused
|
|
||||||
// (https://git.eeqj.de/sneak/AutistMask/issues/350).
|
|
||||||
test("refuses to format when the token reports more than 80 decimals", () => {
|
|
||||||
state.trackedTokens = [
|
|
||||||
{ address: NOVEL, symbol: "NOVEL", decimals: 81 },
|
|
||||||
];
|
|
||||||
expect(swapDetail(data(), "Amount").value).toBe(
|
|
||||||
"1000000000 base units (decimals unknown)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("the Min. received line takes the same rule", () => {
|
describe("the Min. received line takes the same rule", () => {
|
||||||
|
|||||||
+11
-20
@@ -28,14 +28,11 @@ function makeState(overrides = {}) {
|
|||||||
selectedAddress: 0,
|
selectedAddress: 0,
|
||||||
activeAddress: A0,
|
activeAddress: A0,
|
||||||
allowedSites: {
|
allowedSites: {
|
||||||
[A0]: ["https://a.example"],
|
[A0]: ["a.example"],
|
||||||
[A1]: ["https://b.example"],
|
[A1]: ["b.example"],
|
||||||
[B0]: ["https://c.example"],
|
[B0]: ["c.example"],
|
||||||
},
|
|
||||||
deniedSites: {
|
|
||||||
[A1]: ["https://d.example"],
|
|
||||||
[C0]: ["https://e.example"],
|
|
||||||
},
|
},
|
||||||
|
deniedSites: { [A1]: ["d.example"], [C0]: ["e.example"] },
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -44,7 +41,7 @@ describe("removeWalletFromState", () => {
|
|||||||
test("deleting the last wallet clears hasWallet", () => {
|
test("deleting the last wallet clears hasWallet", () => {
|
||||||
const state = makeState({
|
const state = makeState({
|
||||||
wallets: [wallet("A", [A0])],
|
wallets: [wallet("A", [A0])],
|
||||||
allowedSites: { [A0]: ["https://a.example"] },
|
allowedSites: { [A0]: ["a.example"] },
|
||||||
deniedSites: {},
|
deniedSites: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -112,8 +109,8 @@ describe("removeWalletFromState", () => {
|
|||||||
|
|
||||||
removeWalletFromState(state, 0);
|
removeWalletFromState(state, 0);
|
||||||
|
|
||||||
expect(state.allowedSites).toEqual({ [B0]: ["https://c.example"] });
|
expect(state.allowedSites).toEqual({ [B0]: ["c.example"] });
|
||||||
expect(state.deniedSites).toEqual({ [C0]: ["https://e.example"] });
|
expect(state.deniedSites).toEqual({ [C0]: ["e.example"] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,14 +126,8 @@ function makeAddressState(overrides = {}) {
|
|||||||
selectedWallet: 0,
|
selectedWallet: 0,
|
||||||
selectedAddress: 0,
|
selectedAddress: 0,
|
||||||
activeAddress: A0,
|
activeAddress: A0,
|
||||||
allowedSites: {
|
allowedSites: { [A0]: ["a.example"], [A1]: ["b.example"] },
|
||||||
[A0]: ["https://a.example"],
|
deniedSites: { [A1]: ["d.example"], [B0]: ["e.example"] },
|
||||||
[A1]: ["https://b.example"],
|
|
||||||
},
|
|
||||||
deniedSites: {
|
|
||||||
[A1]: ["https://d.example"],
|
|
||||||
[B0]: ["https://e.example"],
|
|
||||||
},
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -282,8 +273,8 @@ describe("removeAddressFromState", () => {
|
|||||||
|
|
||||||
removeAddressFromState(state, 0, 1);
|
removeAddressFromState(state, 0, 1);
|
||||||
|
|
||||||
expect(state.allowedSites).toEqual({ [A0]: ["https://a.example"] });
|
expect(state.allowedSites).toEqual({ [A0]: ["a.example"] });
|
||||||
expect(state.deniedSites).toEqual({ [B0]: ["https://e.example"] });
|
expect(state.deniedSites).toEqual({ [B0]: ["e.example"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The derivation counter is a high-water mark, never rewound: "+" derives
|
// The derivation counter is a high-water mark, never rewound: "+" derives
|
||||||
|
|||||||
Reference in New Issue
Block a user