A token's symbol is whatever its symbol() returns, the block explorer passes it through unfiltered, and balanceLine() interpolated it into an innerHTML string. A token with the 1,000 holders the spam filter asks for, airdropped to the victim, could therefore paint a full-viewport cross-origin iframe over the wallet's own UI, on the screens where the user is used to typing their password. escapeHtml moves to the new src/shared/html.js as a pure string replace over &, <, >, " and '. The implementation it replaces round-tripped through a detached element's textContent, which escapes neither quote character, and it was already in use inside data-copy="..." and would have been inside href="...". Being pure also makes it testable without a DOM shim. Every interpolation into an innerHTML string across src/popup/views/ was audited rather than only the reported one. Also unescaped: the transaction lists' direction label (the explorer's method name, attacker-chosen for an attacker's contract), the wallet name and ENS name in the Home wallet list, the URL in the explorer link's href, the blockie data: URI, and the confirmation screen's warning line, which carries only fixed strings today but is one wiring change from carrying scraped explorer text. Explorer URLs are now built by one helper that percent-encodes the path segment, so a from/to out of explorer JSON cannot re-point the link. Where a value is a markup fragment this code just built, or a loop index, or a locally computed number, it stays bare; the rule and the reason are stated at the top of helpers.js. Both manifests now declare default-src 'self' with frame-src 'none'. Four directives had to stay looser than 'self' and none of them generalises: style-src needs 'unsafe-inline' because the popup sets presentation through style="..." attributes and Firefox has never implemented style-src-attr; img-src needs data: for the blockies; connect-src needs https: and http: because the RPC endpoint is user-configurable and a local node over http://127.0.0.1 is a supported configuration. frame-src, form-action and base-uri are named rather than inherited, because the last two do not fall back to default-src at all. tests/manifest.test.js now pins the whole directive set exactly, in both directions, and README.md carries the reasoning. Displayed symbols are capped at 12 characters, the bound lookupTokenInfo() already applied to a symbol read straight off a contract; the explorer path had none. The cap is a layout bound and is documented as not being the security control. isSpoofedSymbol() is untouched: it answers whether a symbol collides with a known ticker, which is a different question, and repurposing it here would have been the wrong control. Verified failing first, four ways. Restricting escapeHtml to & < > (the escape the old textContent round trip actually performed) fails 5 unit tests including the data-copy attribute break-out. Removing the length cap fails 3. Dropping default-src from manifest/chrome.json fails 2. Removing both the escape and the cap and running the full Chrome suite fails the new browser test with the attack reproduced: an <iframe id="pwn"> in the popup DOM, intercepting pointer events over the Back button.
152 lines
6.5 KiB
JavaScript
152 lines
6.5 KiB
JavaScript
// The shipped Content Security Policy, pinned in both directions.
|
|
//
|
|
// This is the anti-regression check for #182. libsodium decides its
|
|
// backend by trying to compile WebAssembly and catching the failure, so a
|
|
// CSP that refuses WASM demotes the vault to the wasm2js translation —
|
|
// roughly 20x slower per Argon2id derivation — and says so only in a
|
|
// console message nobody reads. Dropping 'wasm-unsafe-eval' from either
|
|
// manifest therefore has to fail a check, not a log line.
|
|
//
|
|
// It is equally a check against loosening. 'wasm-unsafe-eval' is granted
|
|
// deliberately and narrowly (see the backend note in src/shared/vault.js);
|
|
// 'unsafe-eval', 'unsafe-inline' and any remote script source are not, and
|
|
// an exact match on the token set is what keeps the next edit from
|
|
// 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
|
|
// what is asserted here is what ships.
|
|
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
|
|
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
|
|
|
const EXPECTED_DIRECTIVES = {
|
|
"default-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 = [
|
|
"'unsafe-eval'",
|
|
"'unsafe-inline'",
|
|
"http:",
|
|
"https:",
|
|
"data:",
|
|
"blob:",
|
|
"*",
|
|
];
|
|
|
|
function readManifest(name) {
|
|
return JSON.parse(
|
|
fs.readFileSync(path.join(MANIFEST_DIR, name + ".json"), "utf8"),
|
|
);
|
|
}
|
|
|
|
// "script-src 'self'; object-src 'self'" -> { "script-src": ["'self'"], ... }
|
|
function parseCsp(policy) {
|
|
const directives = {};
|
|
for (const part of policy.split(";")) {
|
|
const tokens = part.trim().split(/\s+/).filter(Boolean);
|
|
if (tokens.length === 0) continue;
|
|
directives[tokens[0]] = tokens.slice(1);
|
|
}
|
|
return directives;
|
|
}
|
|
|
|
function assertPolicy(policy) {
|
|
const directives = parseCsp(policy);
|
|
// Exact, in both directions: a directive that appears here and not in
|
|
// EXPECTED_DIRECTIVES is an unreviewed addition, and one that
|
|
// disappears silently reopens whatever it was closing.
|
|
expect(Object.keys(directives).sort()).toEqual(
|
|
Object.keys(EXPECTED_DIRECTIVES).sort(),
|
|
);
|
|
for (const [name, sources] of Object.entries(EXPECTED_DIRECTIVES)) {
|
|
expect([name, directives[name].slice().sort()]).toEqual([
|
|
name,
|
|
sources.slice().sort(),
|
|
]);
|
|
}
|
|
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", () => {
|
|
// MV3 takes an object and applies extension_pages to the popup and the
|
|
// background service worker, which is where libsodium runs.
|
|
test("chrome MV3 ships the pinned policy, default-src included", () => {
|
|
const csp = readManifest("chrome").content_security_policy;
|
|
expect(typeof csp).toBe("object");
|
|
expect(Object.keys(csp)).toEqual(["extension_pages"]);
|
|
assertPolicy(csp.extension_pages);
|
|
});
|
|
|
|
// MV2 takes the policy as a bare string. Firefox does not require
|
|
// 'wasm-unsafe-eval' for MV2 today — enforcement is report-only and
|
|
// Bugzilla 1770909 is still open — so that token is future-proofing
|
|
// for when it lands, not a mandate, and it stays inside Firefox's MV2
|
|
// base-CSP ceiling. object-src 'self' is the load-bearing half: a
|
|
// Firefox before 106 rejects an MV2 policy string that omits
|
|
// object-src and falls back to its own default, discarding everything
|
|
// declared here. Same policy as Chrome, different manifest shape.
|
|
test("firefox MV2 ships the pinned policy, default-src included", () => {
|
|
const csp = readManifest("firefox").content_security_policy;
|
|
expect(typeof csp).toBe("string");
|
|
assertPolicy(csp);
|
|
});
|
|
|
|
// The two targets share one codebase and one crypto path; a policy
|
|
// that drifts apart between them means one of the two builds is
|
|
// running a backend nothing tests.
|
|
test("both targets ship the same policy", () => {
|
|
const chrome =
|
|
readManifest("chrome").content_security_policy.extension_pages;
|
|
const firefox = readManifest("firefox").content_security_policy;
|
|
expect(firefox).toBe(chrome);
|
|
});
|
|
});
|