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:
69
tests/balanceLineEscaping.test.js
Normal file
69
tests/balanceLineEscaping.test.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// 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("<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="" onclick="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"');
|
||||
});
|
||||
});
|
||||
@@ -268,11 +268,15 @@ function ethCallResult(req, opts) {
|
||||
return ZERO_WORD;
|
||||
}
|
||||
|
||||
function tokenObject() {
|
||||
// opts.tokenSymbolOverride is the hostile contract: set it and the explorer
|
||||
// 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 {
|
||||
address_hash: STUB_TOKEN.address,
|
||||
address: STUB_TOKEN.address,
|
||||
symbol: STUB_TOKEN.symbol,
|
||||
symbol: (opts && opts.tokenSymbolOverride) || STUB_TOKEN.symbol,
|
||||
name: STUB_TOKEN.name,
|
||||
decimals: STUB_TOKEN.decimals,
|
||||
holders_count: STUB_TOKEN.holders,
|
||||
@@ -281,7 +285,7 @@ function tokenObject() {
|
||||
}
|
||||
|
||||
// One received ERC-20 transfer of 1.5 E2E to the address under test.
|
||||
function tokenTransferItems(address) {
|
||||
function tokenTransferItems(address, opts) {
|
||||
return [
|
||||
{
|
||||
transaction_hash: STUB_TX_HASH,
|
||||
@@ -290,7 +294,7 @@ function tokenTransferItems(address) {
|
||||
from: { hash: STUB_COUNTERPARTY },
|
||||
to: { hash: address },
|
||||
total: { decimals: STUB_TOKEN.decimals, value: "1500000" },
|
||||
token: tokenObject(),
|
||||
token: tokenObject(opts),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -317,11 +321,11 @@ function nativeTransactionItems(address) {
|
||||
// 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
|
||||
// the only way the confirmation screen's ERC-20 path can be reached.
|
||||
function tokenBalanceItems() {
|
||||
function tokenBalanceItems(opts) {
|
||||
return [
|
||||
{
|
||||
value: "1500000",
|
||||
token: tokenObject(),
|
||||
token: tokenObject(opts),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -596,6 +600,9 @@ function traceEnabled(raw) {
|
||||
* @param {string} [opts.tokenDecimalsOverride] what decimals() answers for
|
||||
* 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.
|
||||
* @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
|
||||
* confirmed receipt instead of null, so a wait screen resolves.
|
||||
* @returns {Promise<{waitForServiceWorkerTraffic: (ms: number) =>
|
||||
@@ -674,14 +681,14 @@ async function installNetworkStubs(ctx, opts) {
|
||||
return jsonResponse(route, {
|
||||
items:
|
||||
opts.seedTokenTransfer && addr
|
||||
? tokenTransferItems(addr)
|
||||
? tokenTransferItems(addr, opts)
|
||||
: [],
|
||||
});
|
||||
}
|
||||
if (/\/addresses\/0x[0-9a-fA-F]{40}\/token-balances$/.test(p)) {
|
||||
return jsonResponse(
|
||||
route,
|
||||
opts.seedTokenBalance ? tokenBalanceItems() : [],
|
||||
opts.seedTokenBalance ? tokenBalanceItems(opts) : [],
|
||||
);
|
||||
}
|
||||
for (const hash of [STUB_TX_HASH, STUB_NATIVE_TX_HASH]) {
|
||||
|
||||
153
tests/e2e/run.js
153
tests/e2e/run.js
@@ -2169,6 +2169,156 @@ test("a token that lies about decimals() at signing time broadcasts nothing (#30
|
||||
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)
|
||||
//
|
||||
// The seam. Everything above drives the popup on its own; this section is
|
||||
@@ -3303,6 +3453,9 @@ async function main() {
|
||||
// something other than the value the same fixture reports through
|
||||
// Blockscout. The token that lies about its scale (#305).
|
||||
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
|
||||
// answering "not mined yet".
|
||||
seedReceipt: false,
|
||||
|
||||
110
tests/htmlEscape.test.js
Normal file
110
tests/htmlEscape.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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("&<>"'");
|
||||
});
|
||||
|
||||
// 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"b");
|
||||
expect(escapeHtml("a'b")).toBe("a'b");
|
||||
});
|
||||
|
||||
test("does not double-escape an ampersand it just introduced", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
expect(escapeHtml("&")).toBe("&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("<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="" onload="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("<b>");
|
||||
});
|
||||
});
|
||||
|
||||
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(
|
||||
"<img src=x>",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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