aboutsummaryrefslogtreecommitdiff
path: root/test-suite
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-31 14:19:04 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-31 14:19:04 -0400
commitd21ebf7b96c90943ab48ffcd6d5b02c6f7438c04 (patch)
tree00a005deb29fd4071c0d67be8e533bab5972d850 /test-suite
parent0fc5b8950601a36dfffdc8f7163bb12fb94f353e (diff)
0.15.29: CSP bypass via CDP Runtime.evaluate for evaluate/snapshot/waitFor/initScript
- evaluateInTab/executeInTab now run through CDP Runtime.evaluate instead of new Function() in MAIN world, so chrome_evaluate/chrome_snapshot work on pages that block unsafe-eval (github.com, many bank/SaaS apps). Preserves value markers (undefined/function/symbol/bigint/Error), DOMRect expansion, and the expression/statement fallback; keeps {ok,value} error envelope. - page.waitFor polls via service-worker-side CDP evaluation. - chrome_navigate initScript uses Page.addScriptToEvaluateOnNewDocument. - Add npm test (test-suite/unit/csp-eval.test.mjs) and in-browser regression challenge 42 strict-csp-evaluate; update challenge 39 notes + docs/primer.
Diffstat (limited to 'test-suite')
-rw-r--r--test-suite/README.md3
-rw-r--r--test-suite/challenges/42-strict-csp-evaluate.html16
-rw-r--r--test-suite/challenges/42-strict-csp-evaluate.js21
-rw-r--r--test-suite/manifest.json53
-rw-r--r--test-suite/unit/csp-eval.test.mjs171
5 files changed, 262 insertions, 2 deletions
diff --git a/test-suite/README.md b/test-suite/README.md
index 5e64efe..47e737f 100644
--- a/test-suite/README.md
+++ b/test-suite/README.md
@@ -125,7 +125,7 @@ Each unit challenge has a `gate` field:
- `dom-complexity` / `frames` — Shadow DOM and iframe targeting.
- `files` — file attachment to `<input type=file>`.
- `observability` — console/network capture tools.
-- `csp` — strict Content Security Policy fallback where eval/snapshot may fail.
+- `csp` — strict Content Security Policy: screenshot/coordinate fallback (39) and the CDP eval/snapshot bypass that works under `script-src 'self'` without `unsafe-eval` (42).
- `lazy-loading` — dynamic DOM readiness and wait behavior.
- `fingerprint` — environment and stack fingerprint probes.
- `agent-safety` — hidden honeypots and safe target selection.
@@ -175,6 +175,7 @@ The dashboard renders this from `manifest.json`. In brief:
39. strict CSP screenshot/coordinate fallback
40. dynamic wait/readiness
41. explicit tab lifecycle
+42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
## Design notes
diff --git a/test-suite/challenges/42-strict-csp-evaluate.html b/test-suite/challenges/42-strict-csp-evaluate.html
new file mode 100644
index 0000000..745c90e
--- /dev/null
+++ b/test-suite/challenges/42-strict-csp-evaluate.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<meta charset="utf-8">
+<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'">
+<title>42 strict CSP evaluate/snapshot</title>
+<link rel="stylesheet" href="../_style.css">
+<script src="../_lib.js"></script>
+<body>
+<main>
+ <p>Goal: this page ships a strict CSP (<code>script-src 'self'</code>, no <code>unsafe-eval</code>), which blocks <code>eval</code>/<code>new Function</code>. <code>chrome_evaluate</code> and <code>chrome_snapshot</code> must still work because they run through CDP, which is not subject to page CSP.</p>
+ <p id="hint">A secret token is exposed only at <code>window.__cspToken</code> — it is never written into the DOM. Use <code>chrome_evaluate</code> to read it, type it into the field (snapshot/uid to find the field), then click Verify.</p>
+ <label for="tokenInput">Token:</label>
+ <input id="tokenInput" type="text" autocomplete="off" aria-label="csp token">
+ <button id="verify" aria-label="verify token">Verify</button>
+</main>
+<script src="42-strict-csp-evaluate.js"></script>
+</body>
diff --git a/test-suite/challenges/42-strict-csp-evaluate.js b/test-suite/challenges/42-strict-csp-evaluate.js
new file mode 100644
index 0000000..57351c0
--- /dev/null
+++ b/test-suite/challenges/42-strict-csp-evaluate.js
@@ -0,0 +1,21 @@
+Challenge.init({
+ id: "strict-csp-evaluate",
+ instructions: "under strict CSP: read window.__cspToken via chrome_evaluate, type it into the field, click Verify",
+});
+
+// Secret available only via JS evaluation. It is intentionally NOT rendered into the DOM and
+// is defined non-enumerable, so the only way to obtain it is to evaluate window.__cspToken in
+// the page (which proves chrome_evaluate works despite script-src 'self' blocking eval).
+const token = "csp-" + Math.random().toString(36).slice(2, 10);
+Object.defineProperty(window, "__cspToken", { value: token, enumerable: false, configurable: false, writable: false });
+
+document.getElementById("verify").addEventListener("click", (e) => {
+ const bad = [];
+ if (!e.isTrusted) bad.push("verify click isTrusted=false (use trusted/CDP input)");
+ const val = (document.getElementById("tokenInput").value || "").trim();
+ if (val !== token) {
+ bad.push(`token mismatch: got "${val}" expected "${token}" — chrome_evaluate must read window.__cspToken under strict CSP`);
+ }
+ if (bad.length) Challenge.fail(...bad);
+ else Challenge.pass("strict CSP: chrome_evaluate read the hidden token via CDP and trusted input submitted it");
+});
diff --git a/test-suite/manifest.json b/test-suite/manifest.json
index 752c455..0ebb313 100644
--- a/test-suite/manifest.json
+++ b/test-suite/manifest.json
@@ -1510,7 +1510,7 @@
"strict-csp"
],
"notes": [
- "chrome_snapshot/chrome_evaluate may fail on this page because script-src omits unsafe-eval; this is intentional. Use screenshot plus viewport coordinates, then read verdict from dashboard/localStorage after leaving CSP page."
+ "This challenge exercises the pure screenshot + viewport-coordinate path (no snapshot/evaluate needed). Note: as of the CDP CSP bypass, chrome_snapshot/chrome_evaluate DO work here even though script-src omits unsafe-eval (see challenge 42 strict-csp-evaluate). Read verdict from dashboard/localStorage after leaving the CSP page."
],
"manualBaseline": "unverified",
"gradeSource": "page"
@@ -1626,5 +1626,56 @@
],
"manualBaseline": "unverified",
"gradeSource": "page"
+ },
+ {
+ "id": "strict-csp-evaluate",
+ "file": "challenges/42-strict-csp-evaluate.html",
+ "category": "csp",
+ "difficulty": "L2",
+ "gate": "core",
+ "goal": "On a page with strict CSP (script-src 'self', no unsafe-eval), use chrome_evaluate + chrome_snapshot via CDP to read a JS-only secret (window.__cspToken), then submit it with trusted input.",
+ "expected": {
+ "synthetic": "FAIL",
+ "trusted": "PASS",
+ "manual": "PASS"
+ },
+ "recipe": [
+ {
+ "tool": "chrome_evaluate",
+ "params": {
+ "expression": "window.__cspToken"
+ }
+ },
+ {
+ "tool": "chrome_fill",
+ "params": {
+ "selector": "#tokenInput",
+ "value": "$RESULT_OF_chrome_evaluate",
+ "trusted": true
+ }
+ },
+ {
+ "tool": "chrome_click",
+ "params": {
+ "selector": "#verify",
+ "trusted": true
+ }
+ }
+ ],
+ "requires": {
+ "cdp": true
+ },
+ "tags": [
+ "csp",
+ "evaluate",
+ "snapshot",
+ "strict-csp",
+ "cdp-bypass"
+ ],
+ "notes": [
+ "Regression guard for the CDP CSP bypass: chrome_evaluate and chrome_snapshot must work even though script-src omits unsafe-eval (eval/new Function are blocked). The token is non-enumerable and never in the DOM, so it can only be obtained via chrome_evaluate. Runner must substitute the evaluate result into the fill value. synthetic FAIL is due to the trusted-click gate, not eval availability."
+ ],
+ "manualBaseline": "unverified",
+ "gradeSource": "page"
}
]
diff --git a/test-suite/unit/csp-eval.test.mjs b/test-suite/unit/csp-eval.test.mjs
new file mode 100644
index 0000000..24101fb
--- /dev/null
+++ b/test-suite/unit/csp-eval.test.mjs
@@ -0,0 +1,171 @@
+// 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",
+ );
+
+ console.log(`\n${passes} passed, ${failures} failed`);
+ if (failures) process.exit(1);
+}
+
+run().catch((e) => { console.error(e); process.exit(1); });