test-suite/unit/csp-eval.test.mjs (view raw)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
// Unit harness for the CSP-bypass layer in service_worker.js.
//
// The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
// proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
// the JS *logic* of the refactor that the bypass depends on:
// - evaluateInTab: wrapper-string construction, expression/statement fallback, value
// marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
// - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
// all real HELPER_FUNCS serialize+assign without a parse error.
// - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
//
// We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
// shim that evaluates the expression in a separate "page world" vm context (simulating CDP
// Runtime.evaluate returnByValue). No browser, no network, no deps.
import vm from "node:vm";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
const src = fs.readFileSync(workerPath, "utf8");
let failures = 0;
let passes = 0;
function ok(cond, msg) {
if (cond) { passes++; }
else { failures++; console.error(` ✗ ${msg}`); }
}
async function throwsWith(fn, re, msg) {
try { await fn(); ok(false, `${msg} (expected throw)`); }
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
}
// ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
const pageGlobals = {
console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
setTimeout, parseInt, parseFloat, isNaN,
document: {
title: "page title",
_present: new Set(),
querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
},
};
pageGlobals.window = pageGlobals;
pageGlobals.globalThis = pageGlobals;
const pageWorld = vm.createContext(pageGlobals);
// Simulate CDP Runtime.evaluate returnByValue serialization.
function toCdpResult(v) {
if (v === undefined) return { result: { type: "undefined" } };
if (v === null) return { result: { type: "object", subtype: "null", value: null } };
const t = typeof v;
if (t === "number" || t === "string" || t === "boolean")
return { result: { type: t, value: v } };
// object/array: returnByValue deep-clones JSON-able structures
return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
}
// ---- worker sandbox ----
const noop = () => {};
const listener = { addListener: noop, removeListener: noop };
const sandbox = {
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
setTimeout, clearTimeout,
setInterval: () => 0,
clearInterval: noop,
fetch: async () => { throw new Error("no network in unit test"); },
navigator: { userAgent: "unit-test" },
WebSocket: function () {},
chrome: {
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
action: { onClicked: listener },
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
scripting: { executeScript: async () => [{ result: undefined }] },
tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
windows: { update: async () => {} },
webNavigation: { onCommitted: listener },
},
};
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
vm.createContext(sandbox);
vm.runInContext(src, sandbox);
// ---- override the page-touching primitives with the page-world shim ----
sandbox.attachDebugger = async () => ({});
sandbox.bringToFront = async () => {};
sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
sandbox.cdp = async (_tabId, method, params) => {
if (method !== "Runtime.evaluate") return {};
try {
const value = await vm.runInContext(params.expression, pageWorld);
return toCdpResult(value);
} catch (e) {
return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
}
};
// Phase-2 of executeInTab: run the injected wrapper func against the page world,
// where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
const result = await fn(...(args || []));
return [{ result }];
};
const { evaluateInTab, executeInTab, dispatch } = sandbox;
async function run() {
// ===== evaluateInTab: primitives & objects =====
ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
// ===== value markers =====
ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
// DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
// ===== statement-form fallback (expression form is a SyntaxError) =====
// `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
// ===== error propagation =====
await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
// ===== executeInTab: 2-phase define + invoke =====
// Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
ok(sum === 7, "executeInTab: action runs with args after helper injection");
const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
ok(asyncResult === 42, "executeInTab: async action awaited");
await throwsWith(
() => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
/action failed/,
"executeInTab: thrown action error propagates via envelope",
);
// ===== page.waitFor (service-worker-side polling) =====
pageGlobals.document._present.add("#ready");
const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
await throwsWith(
() => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
/Timed out after 120ms/,
"waitFor: missing selector times out",
);
// ===== usKeyLayoutForChar / cdpKeyInfo: US-layout key codes =====
// Regression: punctuation must NOT use charCodeAt() (".":46 collides with VK_DELETE,
// "-":45 with VK_INSERT), which made apps drop the char on keydown.
const { usKeyLayoutForChar, cdpKeyInfo } = sandbox;
const period = usKeyLayoutForChar(".");
ok(period.code === "Period" && period.keyCode === 190 && !period.needShift, "keylayout: '.' -> Period/190 (not 46)");
const dash = usKeyLayoutForChar("-");
ok(dash.code === "Minus" && dash.keyCode === 189, "keylayout: '-' -> Minus/189 (not 45)");
const slash = usKeyLayoutForChar("/");
ok(slash.code === "Slash" && slash.keyCode === 191, "keylayout: '/' -> Slash/191");
const at = usKeyLayoutForChar("@");
ok(at.code === "Digit2" && at.keyCode === 50 && at.needShift, "keylayout: '@' -> Digit2/50 + shift");
const A = usKeyLayoutForChar("A");
ok(A.code === "KeyA" && A.keyCode === 65 && A.needShift, "keylayout: 'A' -> KeyA/65 + shift");
const a = usKeyLayoutForChar("a");
ok(a.code === "KeyA" && a.keyCode === 65 && !a.needShift, "keylayout: 'a' -> KeyA/65 no shift");
const dot = cdpKeyInfo(".");
ok(dot.code === "Period" && dot.windowsVirtualKeyCode === 190 && dot.text === ".", "cdpKeyInfo: '.' -> Period/190 with text");
const ent = cdpKeyInfo("Enter");
ok(ent.code === "Enter" && ent.windowsVirtualKeyCode === 13, "cdpKeyInfo: named key 'Enter' unaffected");
console.log(`\n${passes} passed, ${failures} failed`);
if (failures) process.exit(1);
}
run().catch((e) => { console.error(e); process.exit(1); });
|