Neither manifest declared an `icons` block, so Chrome and Firefox both drew the generic puzzle piece for this extension. That is the first thing the owner sees on every launch, and an unbranded placeholder is also how a user fails to tell a real extension from a look-alike. Both manifests now declare 16/32/48/128 as `icons/icon<size>.png`, and the four PNGs live at `icons/` in the tree. build.js copies them into each browser directory relative to it, so nothing points up and out the way `dist/styles.css` does, and the receipt records them like every other emitted file. The sizes copied come from the manifest that will ship next to them, not from a second list in build.js: a size a manifest declares and `icons/` does not hold fails the build with the path that is missing, rather than emitting a directory whose manifest references nothing. script/lib/package.js already resolved `.png` strings, so an icon that reached a manifest but not the archive fails self-containment; tests/packaging.test.js now pins that case, since it was covered only incidentally before. tests/manifest.test.js asserts the declaration in both manifests, that both declare the same set, and that each referenced file is a PNG whose IHDR states the size the entry claims — a declaration alone would still permit a reference to a file that is not there or is not an image. The artwork is original, drawn from geometry rather than traced or downloaded: a flat dark-navy rounded square (#101A2E) with a teal (#35E0C2) triangular "A" — one outer triangle minus a triangular counter — rasterised with 8x8 supersampling and encoded as RGBA PNG. One shape, two flat colours, which is what a 16px toolbar slot can carry. Verified: make check 56 suites / 1023 tests, make build and make package green, both archives unpacked and the four icons confirmed inside each with bytes identical to the tree, make test-e2e 55/55 and 5/5, make test-e2e-firefox 8/8 and 7/7 (the latter installs the packaged XPI). Removing icons/icon48.png fails make build; making build.js skip one copy fails make package with "the chrome archive would not be self-contained: it is told to load icons/icon48.png, which is not in it".
203 lines
8.5 KiB
JavaScript
203 lines
8.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 ROOT = path.join(__dirname, "..");
|
|
|
|
// The sizes both stores and both toolbars ask for.
|
|
const EXPECTED_ICON_SIZES = ["16", "32", "48", "128"];
|
|
|
|
const PNG_SIGNATURE = Buffer.from("89504e470d0a1a0a", "hex");
|
|
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The declared icons, in both manifests.
|
|
//
|
|
// Without an "icons" block a browser draws a generic puzzle piece in the
|
|
// toolbar for this extension, which is both the first thing the user sees and
|
|
// how a real extension is told apart from a look-alike. Declaring one is not
|
|
// enough on its own: an entry naming a file that is not in the tree ships a
|
|
// reference to nothing, so the referenced bytes are read here and required to
|
|
// be a PNG of the size the entry claims. build.js copies these into each
|
|
// browser directory, relative to it, and script/lib/package.js then refuses to
|
|
// build an archive that does not contain everything the manifest names.
|
|
function assertIcons(target) {
|
|
const icons = readManifest(target).icons;
|
|
expect(Object.keys(icons).sort()).toEqual(EXPECTED_ICON_SIZES.sort());
|
|
for (const size of EXPECTED_ICON_SIZES) {
|
|
const ref = icons[size];
|
|
expect([size, ref]).toEqual([size, `icons/icon${size}.png`]);
|
|
|
|
const bytes = fs.readFileSync(path.join(ROOT, ref));
|
|
expect(bytes.subarray(0, 8)).toEqual(PNG_SIGNATURE);
|
|
// IHDR width and height, at fixed offsets right after the signature
|
|
// and the chunk header.
|
|
expect([ref, bytes.readUInt32BE(16), bytes.readUInt32BE(20)]).toEqual([
|
|
ref,
|
|
Number(size),
|
|
Number(size),
|
|
]);
|
|
}
|
|
}
|
|
|
|
describe("declared icons", () => {
|
|
test("chrome declares real icons at every size", () => {
|
|
assertIcons("chrome");
|
|
});
|
|
|
|
test("firefox declares real icons at every size", () => {
|
|
assertIcons("firefox");
|
|
});
|
|
|
|
test("both targets declare the same icons", () => {
|
|
expect(readManifest("firefox").icons).toEqual(
|
|
readManifest("chrome").icons,
|
|
);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|