harden: escape every interpolation into popup innerHTML, and add default-src to both manifests (closes #307)
A hostile ERC-20's symbol() reached an innerHTML string unescaped, and neither manifest declared default-src, so an attacker deploying a token with 1,000+ holders and airdropping one unit could render a full-viewport cross-origin iframe over the wallet's own UI, on screens where the user types their password. escapeHtml is now a pure string replace over & < > " ' — the old version round-tripped through textContent, which escapes neither quote, while already being used inside data-copy="...". All 19 files in src/popup/views/ were audited: beyond the reported symbol site, the explorer-supplied directionLabel in all three transaction lists, wallet.name, addr.ensName, the blockie data: URIs and two ad-hoc quote-only escapes were also unescaped. Explorer URLs now go through one helper that percent-encodes the path segment. Both manifests add default-src 'self', frame-src 'none', form-action 'none' and base-uri 'none'. Three loosenings are pinned in tests/manifest.test.js and justified in README.md: style-src 'unsafe-inline' (39 static style attributes; Firefox implements neither style-src-attr nor 'unsafe-hashes'), img-src data: (blockies), connect-src https: http: (user-configurable RPC). Note frame-src 'none' blocks a frame loading, not the element existing, so the zero-iframe assertion is a claim about the escaping alone; the test asserts the element count and the literal rendered text separately, taking the count before any click an overlay could intercept. Verified: make check 39 suites / 811 tests, test-e2e 55/55 including the WebAssembly-under-CSP assertion, test-e2e-firefox 8/8, zero CSP violations asserted rather than merely unobserved. Reverting only balanceLine's interpolation reproduces the attack as 2 iframes on the address screen.
This commit was merged in pull request #327.
This commit is contained in:
@@ -13,6 +13,33 @@
|
||||
// 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.
|
||||
|
||||
@@ -21,8 +48,22 @@ const path = require("path");
|
||||
|
||||
const MANIFEST_DIR = path.join(__dirname, "..", "manifest");
|
||||
|
||||
const EXPECTED_SCRIPT_SRC = ["'self'", "'wasm-unsafe-eval'"];
|
||||
const EXPECTED_OBJECT_SRC = ["'self'"];
|
||||
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'",
|
||||
@@ -53,26 +94,31 @@ function parseCsp(policy) {
|
||||
|
||||
function assertPolicy(policy) {
|
||||
const directives = parseCsp(policy);
|
||||
expect(Object.keys(directives).sort()).toEqual([
|
||||
"object-src",
|
||||
"script-src",
|
||||
]);
|
||||
expect(directives["script-src"].slice().sort()).toEqual(
|
||||
EXPECTED_SCRIPT_SRC,
|
||||
// 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(),
|
||||
);
|
||||
expect(directives["object-src"].slice().sort()).toEqual(
|
||||
EXPECTED_OBJECT_SRC,
|
||||
);
|
||||
for (const source of FORBIDDEN_SOURCES) {
|
||||
expect(directives["script-src"]).not.toContain(source);
|
||||
expect(directives["object-src"]).not.toContain(source);
|
||||
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 allows WASM and nothing else beyond 'self'", () => {
|
||||
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"]);
|
||||
@@ -87,7 +133,7 @@ describe("shipped Content Security Policy", () => {
|
||||
// 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 allows WASM and nothing else beyond 'self'", () => {
|
||||
test("firefox MV2 ships the pinned policy, default-src included", () => {
|
||||
const csp = readManifest("firefox").content_security_policy;
|
||||
expect(typeof csp).toBe("string");
|
||||
assertPolicy(csp);
|
||||
|
||||
Reference in New Issue
Block a user