// 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. // // And for #328: style-src is 'self' alone, so the browser refuses every // style="..." attribute in the popup's markup, including one an escape lets // through. The popup styles with classes; script setting element.style is // not affected. // // Every directive below is pinned exactly, because each of the three // loosenings is load-bearing and none of them may grow: // // 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//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'"], "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); }); });