pi-chrome 0.15.28 → 0.15.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +1 -1
- package/docs/COMPARISON.md +1 -1
- package/docs/EXAMPLES.md +1 -1
- package/docs/FAQ.md +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/manifest.json +1 -1
- package/extensions/chrome-profile-bridge/browser-extension/service_worker.js +131 -108
- package/extensions/chrome-profile-bridge/index.ts +1 -1
- package/package.json +2 -1
- package/test-suite/README.md +2 -1
- package/test-suite/challenges/42-strict-csp-evaluate.html +16 -0
- package/test-suite/challenges/42-strict-csp-evaluate.js +21 -0
- package/test-suite/manifest.json +52 -1
- package/test-suite/unit/csp-eval.test.mjs +171 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## 0.15.29 — 2026-05-31
|
|
6
|
+
|
|
7
|
+
Strict-CSP support: `chrome_evaluate`, `chrome_snapshot`, `chrome_wait_for`, and `chrome_navigate initScript` now work on pages that block `unsafe-eval`.
|
|
8
|
+
|
|
9
|
+
- **CDP-based evaluation bypasses page CSP.** `chrome_evaluate`/`chrome_snapshot` (and all snapshot-driven inspection) previously ran user code in the page MAIN world via the **Function constructor**, which is blocked by `script-src 'self'` without `'unsafe-eval'` — so they returned null/empty (or `EvalError`) on github.com and many bank/SaaS apps. They now evaluate through CDP `Runtime.evaluate`, a DevTools protocol command that is not subject to the page's Content-Security-Policy. Rich return values (undefined/function/symbol/bigint/Error markers, DOMRect expansion) and the expression/statement fallback are preserved.
|
|
10
|
+
- **`chrome_wait_for` polls via CDP.** The selector/expression polling loop moved from in-page `new Function()` to service-worker-side CDP evaluation, so waits work under strict CSP too.
|
|
11
|
+
- **`chrome_navigate initScript` injects via CDP.** Document-start init scripts now register with `Page.addScriptToEvaluateOnNewDocument` instead of `new Function()` on `webNavigation.onCommitted`, so seeding localStorage / stubbing `Date.now` works under strict CSP.
|
|
12
|
+
- **Tests.** Added a Node unit harness (`test-suite/unit/csp-eval.test.mjs`, run via `npm test`) validating the evaluate/execute/waitFor refactor, and an in-browser regression challenge (42 `strict-csp-evaluate`) that reads a JS-only secret under strict CSP. Updated challenge 39's notes and docs (FAQ, EXAMPLES, COMPARISON, primer) which previously stated eval/snapshot fail under strict CSP.
|
|
13
|
+
|
|
5
14
|
## 0.15.28 — 2026-05-31
|
|
6
15
|
|
|
7
16
|
Low-risk reliability fixes from a long-session bug report.
|
package/README.md
CHANGED
|
@@ -217,7 +217,7 @@ Multiple Pi sessions (planner / worker / audit) can all drive the same Chrome at
|
|
|
217
217
|
|
|
218
218
|
## Built-in benchmark suite
|
|
219
219
|
|
|
220
|
-
[`test-suite/`](./test-suite) is a benchmark for **any** browser-control agent (not just pi-chrome). It includes **
|
|
220
|
+
[`test-suite/`](./test-suite) is a benchmark for **any** browser-control agent (not just pi-chrome). It includes **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks**.
|
|
221
221
|
|
|
222
222
|
Scoring tracks expected outcomes per challenge rather than raw PASS count, so tools are judged against their declared browser-control capability. Unit challenges are split into gate buckets:
|
|
223
223
|
|
package/docs/COMPARISON.md
CHANGED
|
@@ -134,7 +134,7 @@ If your threat model excludes extensions with broad permissions, neither approac
|
|
|
134
134
|
|
|
135
135
|
## Public benchmarks worth knowing (for axis 2 / axis 3 comparison)
|
|
136
136
|
|
|
137
|
-
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **
|
|
137
|
+
Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks** covering trusted input, pointer humanization, keyboard fidelity, drag/drop, Shadow DOM, iframes, file uploads, strict-CSP screenshot fallback and CDP eval/snapshot bypass, dynamic waits, tab lifecycle, network observability, fingerprint leaks, and agent-safety honeypots. Scoring tracks expected outcomes per challenge instead of raw PASS count, with `core`, `conditional`, and `quality` gate buckets. That's **driver-level** grading.
|
|
138
138
|
|
|
139
139
|
For **agent-level** comparison (axis 2), the public benchmarks worth citing:
|
|
140
140
|
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -161,6 +161,6 @@ Interactive tools use Chrome's real input layer by default: clicks, typing, fill
|
|
|
161
161
|
- fullscreen and other user-activation checks
|
|
162
162
|
- pages where DOM injection/evaluate is limited, if the agent can use screenshots + coordinates
|
|
163
163
|
|
|
164
|
-
Strict CSP note: `chrome_snapshot`/`chrome_evaluate`
|
|
164
|
+
Strict CSP note: `chrome_snapshot`/`chrome_evaluate` work even on pages that disallow `unsafe-eval`, because they run via CDP `Runtime.evaluate` (not page-level `eval`/`new Function`), which is not subject to page CSP. `chrome_screenshot`, tab/navigation tools, and real input also work under any CSP.
|
|
165
165
|
|
|
166
166
|
Chrome may show its debugger banner while pi-chrome is attached.
|
package/docs/FAQ.md
CHANGED
|
@@ -55,7 +55,7 @@ pi-chrome controls web pages through Chrome extension APIs, page inspection, scr
|
|
|
55
55
|
|
|
56
56
|
## Does `chrome_evaluate` work on strict-CSP pages?
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
Yes. `chrome_evaluate` and `chrome_snapshot` run in the page's MAIN world through CDP `Runtime.evaluate`, which is a DevTools protocol command and is **not** subject to the page's Content-Security-Policy. They work even on pages that block `'unsafe-eval'` (e.g. github.com and many bank/SaaS apps). `chrome_navigate`'s `initScript` injects at document_start via CDP and likewise bypasses CSP. `chrome_screenshot`, tab tools, and real Chrome input also keep working under any CSP.
|
|
59
59
|
|
|
60
60
|
## How do I tell whether a click or type worked?
|
|
61
61
|
|
|
@@ -217,6 +217,38 @@ async function cdp(tabId, method, params) {
|
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
+
// cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP
|
|
221
|
+
// Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to
|
|
222
|
+
// the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'`
|
|
223
|
+
// without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is
|
|
224
|
+
// attached first. Returns the raw CDP result ({ result, exceptionDetails }).
|
|
225
|
+
async function cdpEval(tabId, expression, opts) {
|
|
226
|
+
await attachDebugger(tabId);
|
|
227
|
+
return cdp(tabId, "Runtime.evaluate", {
|
|
228
|
+
expression,
|
|
229
|
+
returnByValue: true,
|
|
230
|
+
awaitPromise: true,
|
|
231
|
+
userGesture: true,
|
|
232
|
+
...(opts || {}),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cdpExceptionText(details) {
|
|
237
|
+
if (!details) return "";
|
|
238
|
+
return String(
|
|
239
|
+
details.exception?.description ||
|
|
240
|
+
details.exception?.value ||
|
|
241
|
+
details.text ||
|
|
242
|
+
"",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function cdpIsSyntaxError(details) {
|
|
247
|
+
if (!details) return false;
|
|
248
|
+
const className = String(details.exception?.className || "");
|
|
249
|
+
return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details));
|
|
250
|
+
}
|
|
251
|
+
|
|
220
252
|
// Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
|
|
221
253
|
async function resolveTargetInTab(tabId, params) {
|
|
222
254
|
const results = await chrome.scripting.executeScript({
|
|
@@ -739,8 +771,29 @@ async function dispatch(action, params) {
|
|
|
739
771
|
return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]);
|
|
740
772
|
case "page.network.get":
|
|
741
773
|
return executeInTab(params, getNetworkRequest, [params.requestId]);
|
|
742
|
-
case "page.waitFor":
|
|
743
|
-
|
|
774
|
+
case "page.waitFor": {
|
|
775
|
+
// Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
|
|
776
|
+
// loop in-page with new Function() for expression checks, which fails under strict CSP.
|
|
777
|
+
const tab = await getTabByParams(params);
|
|
778
|
+
if (params.foreground) await bringToFront(tab);
|
|
779
|
+
const timeoutMs = params.timeoutMs || 10000;
|
|
780
|
+
const intervalMs = params.intervalMs || 250;
|
|
781
|
+
const started = Date.now();
|
|
782
|
+
while (Date.now() - started < timeoutMs) {
|
|
783
|
+
let ok = false;
|
|
784
|
+
try {
|
|
785
|
+
const expr = params.kind === "selector"
|
|
786
|
+
? `!!document.querySelector(${JSON.stringify(params.value)})`
|
|
787
|
+
: params.value;
|
|
788
|
+
ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false }));
|
|
789
|
+
} catch {
|
|
790
|
+
ok = false;
|
|
791
|
+
}
|
|
792
|
+
if (ok) return { elapsedMs: Date.now() - started };
|
|
793
|
+
await sleep(intervalMs);
|
|
794
|
+
}
|
|
795
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`);
|
|
796
|
+
}
|
|
744
797
|
case "page.probe":
|
|
745
798
|
// Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
|
|
746
799
|
return executeInTab(params, probePage, []);
|
|
@@ -847,25 +900,33 @@ const HELPER_FUNCS = [
|
|
|
847
900
|
async function executeInTab(params, func, args) {
|
|
848
901
|
const tab = await getTabByParams(params);
|
|
849
902
|
if (params.foreground) await bringToFront(tab);
|
|
850
|
-
|
|
903
|
+
|
|
904
|
+
// Phase 1: define the helpers and the action function as page globals via CDP
|
|
905
|
+
// Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
|
|
906
|
+
// root cause of snapshot/click/etc silently failing on `script-src 'self'` sites.
|
|
907
|
+
// Each helper is a named function declaration, assigned to window.<name> so the action
|
|
908
|
+
// (which references helpers by bare name) resolves them as globals at call time.
|
|
909
|
+
const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n");
|
|
910
|
+
const actionAssign = `window.__piAction=(${func.toString()})`;
|
|
911
|
+
const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`);
|
|
912
|
+
if (defineRes.exceptionDetails) {
|
|
913
|
+
throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
|
|
917
|
+
// injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
|
|
918
|
+
// serialize the invocation args. The wrapper references window.__piAction defined above.
|
|
851
919
|
const results = await chrome.scripting.executeScript({
|
|
852
920
|
target: { tabId: tab.id },
|
|
853
921
|
world: "MAIN",
|
|
854
|
-
func: async (
|
|
922
|
+
func: async (invocationArgs) => {
|
|
855
923
|
try {
|
|
856
|
-
|
|
857
|
-
// running through `eval` (which is restricted under strict CSP) and keeps them isolated.
|
|
858
|
-
new Function(helperSource).call(globalThis);
|
|
859
|
-
// The action itself is reconstructed from its source text. We use `new Function` rather
|
|
860
|
-
// than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`)
|
|
861
|
-
// CSPs that are common on production sites.
|
|
862
|
-
const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis);
|
|
863
|
-
return { ok: true, value: await injected(...invocationArgs) };
|
|
924
|
+
return { ok: true, value: await window.__piAction(...invocationArgs) };
|
|
864
925
|
} catch (error) {
|
|
865
926
|
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
866
927
|
}
|
|
867
928
|
},
|
|
868
|
-
args: [
|
|
929
|
+
args: [args || []],
|
|
869
930
|
});
|
|
870
931
|
const first = results?.[0];
|
|
871
932
|
if (first?.error) {
|
|
@@ -879,72 +940,54 @@ async function executeInTab(params, func, args) {
|
|
|
879
940
|
return envelope?.value;
|
|
880
941
|
}
|
|
881
942
|
|
|
882
|
-
//
|
|
883
|
-
//
|
|
884
|
-
//
|
|
943
|
+
// Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated
|
|
944
|
+
// expression so we can return rich markers for values that don't survive returnByValue
|
|
945
|
+
// (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields
|
|
946
|
+
// are non-enumerable. Kept as a standalone function so it stays editable/lintable.
|
|
947
|
+
function piEvalStringify(v) {
|
|
948
|
+
if (v === undefined) return { kind: "undefined" };
|
|
949
|
+
if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
|
|
950
|
+
if (typeof v === "symbol") return { kind: "symbol", description: v.description };
|
|
951
|
+
if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
|
|
952
|
+
if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
|
|
953
|
+
// DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
|
|
954
|
+
// properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
|
|
955
|
+
if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
|
|
956
|
+
(typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
|
|
957
|
+
(v && typeof v === "object" && typeof v.toJSON === "function" &&
|
|
958
|
+
typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
|
|
959
|
+
return { x: v.x, y: v.y, width: v.width, height: v.height, top: v.top, right: v.right, bottom: v.bottom, left: v.left };
|
|
960
|
+
}
|
|
961
|
+
return v;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not
|
|
965
|
+
// subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on
|
|
966
|
+
// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
|
|
885
967
|
async function evaluateInTab(params) {
|
|
886
968
|
const tab = await getTabByParams(params);
|
|
887
969
|
if (params.foreground) await bringToFront(tab);
|
|
888
970
|
const expression = String(params.expression ?? "");
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
};
|
|
910
|
-
// Compile via the Function constructor. We try expression form first so callers can pass
|
|
911
|
-
// `1+1` or `document.title` without a `return`; if that's a SyntaxError we retry with the
|
|
912
|
-
// statement form so callers can use multi-statement bodies (loops, var decls, etc).
|
|
913
|
-
const compile = (src) => {
|
|
914
|
-
try {
|
|
915
|
-
return { fn: new Function(`return (async () => (${src}))();`), mode: "expression" };
|
|
916
|
-
} catch (e1) {
|
|
917
|
-
if (e1 && e1.name === "SyntaxError") {
|
|
918
|
-
try {
|
|
919
|
-
return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" };
|
|
920
|
-
} catch (e2) {
|
|
921
|
-
throw e2;
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
throw e1;
|
|
925
|
-
}
|
|
926
|
-
};
|
|
927
|
-
try {
|
|
928
|
-
const { fn } = compile(expression);
|
|
929
|
-
const value = await fn.call(globalThis);
|
|
930
|
-
const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value;
|
|
931
|
-
return { ok: true, value: stringify(resolved) };
|
|
932
|
-
} catch (error) {
|
|
933
|
-
return { ok: false, error: error?.stack || error?.message || String(error) };
|
|
934
|
-
}
|
|
935
|
-
},
|
|
936
|
-
args: [expression, awaitPromise],
|
|
937
|
-
});
|
|
938
|
-
const first = results?.[0];
|
|
939
|
-
if (first?.error) {
|
|
940
|
-
const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
|
|
941
|
-
throw new Error(`chrome_evaluate failed: ${message}`);
|
|
942
|
-
}
|
|
943
|
-
const envelope = first?.result;
|
|
944
|
-
if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world");
|
|
945
|
-
if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed");
|
|
946
|
-
const v = envelope.value;
|
|
947
|
-
// Unwrap special markers from MAIN world
|
|
971
|
+
const stringifySrc = `(${piEvalStringify.toString()})`;
|
|
972
|
+
// Wrap the user expression so the result is run through piEvalStringify in-page before it
|
|
973
|
+
// crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title`
|
|
974
|
+
// work without `return`); on a SyntaxError fall back to statement form for multi-statement
|
|
975
|
+
// bodies (loops, var decls, etc), matching the previous new Function() two-form behavior.
|
|
976
|
+
const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`;
|
|
977
|
+
const exprForm = `(async () => (${expression}))()`;
|
|
978
|
+
const stmtForm = `(async () => { ${expression} })()`;
|
|
979
|
+
|
|
980
|
+
let res = await cdpEval(tab.id, buildWrapper(exprForm));
|
|
981
|
+
if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) {
|
|
982
|
+
res = await cdpEval(tab.id, buildWrapper(stmtForm));
|
|
983
|
+
}
|
|
984
|
+
if (res.exceptionDetails) {
|
|
985
|
+
throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`);
|
|
986
|
+
}
|
|
987
|
+
const result = res.result;
|
|
988
|
+
if (!result || result.type === "undefined") return undefined;
|
|
989
|
+
const v = result.value;
|
|
990
|
+
// Unwrap special markers produced by piEvalStringify.
|
|
948
991
|
if (v && typeof v === "object" && !Array.isArray(v)) {
|
|
949
992
|
if (v.kind === "undefined") return undefined;
|
|
950
993
|
if (v.kind === "function") return `[Function: ${v.source}]`;
|
|
@@ -964,29 +1007,23 @@ async function withOptionalSnapshot(params, actionFn) {
|
|
|
964
1007
|
return result;
|
|
965
1008
|
}
|
|
966
1009
|
|
|
967
|
-
// One-shot init script registry, scoped per tab. The
|
|
968
|
-
//
|
|
969
|
-
|
|
1010
|
+
// One-shot init script registry, scoped per tab. The source is registered with CDP
|
|
1011
|
+
// Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN
|
|
1012
|
+
// world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was
|
|
1013
|
+
// blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters
|
|
1014
|
+
// after load, so only the intended navigation receives the script.
|
|
1015
|
+
const initScriptIds = new Map(); // tabId -> CDP script identifier
|
|
970
1016
|
async function registerInitScript(tabId, source) {
|
|
971
|
-
|
|
1017
|
+
await attachDebugger(tabId);
|
|
1018
|
+
await cdp(tabId, "Page.enable", {}).catch(() => undefined);
|
|
1019
|
+
const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source });
|
|
1020
|
+
if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier);
|
|
972
1021
|
}
|
|
973
1022
|
async function unregisterInitScript(tabId) {
|
|
1023
|
+
const identifier = initScriptIds.get(tabId);
|
|
1024
|
+
if (identifier === undefined) return;
|
|
974
1025
|
initScriptIds.delete(tabId);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
|
|
978
|
-
chrome.webNavigation.onCommitted.addListener((details) => {
|
|
979
|
-
if (details.frameId !== 0) return;
|
|
980
|
-
const source = initScriptIds.get(details.tabId);
|
|
981
|
-
if (!source) return;
|
|
982
|
-
chrome.scripting.executeScript({
|
|
983
|
-
target: { tabId: details.tabId, frameIds: [0] },
|
|
984
|
-
world: "MAIN",
|
|
985
|
-
injectImmediately: true,
|
|
986
|
-
func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } },
|
|
987
|
-
args: [source],
|
|
988
|
-
}).catch(() => undefined);
|
|
989
|
-
});
|
|
1026
|
+
await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
|
|
990
1027
|
}
|
|
991
1028
|
|
|
992
1029
|
// Always inject early console/network capture at document_start on every navigation.
|
|
@@ -2076,20 +2113,6 @@ function getNetworkRequest(requestId) {
|
|
|
2076
2113
|
return request;
|
|
2077
2114
|
}
|
|
2078
2115
|
|
|
2079
|
-
async function waitForPage(kind, value, timeoutMs, intervalMs) {
|
|
2080
|
-
const started = Date.now();
|
|
2081
|
-
while (Date.now() - started < timeoutMs) {
|
|
2082
|
-
let ok = false;
|
|
2083
|
-
if (kind === "selector") ok = Boolean(document.querySelector(value));
|
|
2084
|
-
else {
|
|
2085
|
-
try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; }
|
|
2086
|
-
}
|
|
2087
|
-
if (ok) return { elapsedMs: Date.now() - started };
|
|
2088
|
-
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
2089
|
-
}
|
|
2090
|
-
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${kind}: ${value}`);
|
|
2091
|
-
}
|
|
2092
|
-
|
|
2093
2116
|
function normalizeKey(key) {
|
|
2094
2117
|
const table = {
|
|
2095
2118
|
enter: "Enter",
|
|
@@ -665,7 +665,7 @@ Chrome control is available through the chrome_* tools via a companion Chrome ex
|
|
|
665
665
|
Capability model (important):
|
|
666
666
|
- Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
|
|
667
667
|
- Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
|
|
668
|
-
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via
|
|
668
|
+
- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
|
|
669
669
|
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly.
|
|
670
670
|
|
|
671
671
|
Usage rules:
|
package/package.json
CHANGED
package/test-suite/README.md
CHANGED
|
@@ -125,7 +125,7 @@ Each unit challenge has a `gate` field:
|
|
|
125
125
|
- `dom-complexity` / `frames` — Shadow DOM and iframe targeting.
|
|
126
126
|
- `files` — file attachment to `<input type=file>`.
|
|
127
127
|
- `observability` — console/network capture tools.
|
|
128
|
-
- `csp` — strict Content Security Policy fallback
|
|
128
|
+
- `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).
|
|
129
129
|
- `lazy-loading` — dynamic DOM readiness and wait behavior.
|
|
130
130
|
- `fingerprint` — environment and stack fingerprint probes.
|
|
131
131
|
- `agent-safety` — hidden honeypots and safe target selection.
|
|
@@ -175,6 +175,7 @@ The dashboard renders this from `manifest.json`. In brief:
|
|
|
175
175
|
39. strict CSP screenshot/coordinate fallback
|
|
176
176
|
40. dynamic wait/readiness
|
|
177
177
|
41. explicit tab lifecycle
|
|
178
|
+
42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
|
|
178
179
|
|
|
179
180
|
## Design notes
|
|
180
181
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'">
|
|
4
|
+
<title>42 strict CSP evaluate/snapshot</title>
|
|
5
|
+
<link rel="stylesheet" href="../_style.css">
|
|
6
|
+
<script src="../_lib.js"></script>
|
|
7
|
+
<body>
|
|
8
|
+
<main>
|
|
9
|
+
<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>
|
|
10
|
+
<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>
|
|
11
|
+
<label for="tokenInput">Token:</label>
|
|
12
|
+
<input id="tokenInput" type="text" autocomplete="off" aria-label="csp token">
|
|
13
|
+
<button id="verify" aria-label="verify token">Verify</button>
|
|
14
|
+
</main>
|
|
15
|
+
<script src="42-strict-csp-evaluate.js"></script>
|
|
16
|
+
</body>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Challenge.init({
|
|
2
|
+
id: "strict-csp-evaluate",
|
|
3
|
+
instructions: "under strict CSP: read window.__cspToken via chrome_evaluate, type it into the field, click Verify",
|
|
4
|
+
});
|
|
5
|
+
|
|
6
|
+
// Secret available only via JS evaluation. It is intentionally NOT rendered into the DOM and
|
|
7
|
+
// is defined non-enumerable, so the only way to obtain it is to evaluate window.__cspToken in
|
|
8
|
+
// the page (which proves chrome_evaluate works despite script-src 'self' blocking eval).
|
|
9
|
+
const token = "csp-" + Math.random().toString(36).slice(2, 10);
|
|
10
|
+
Object.defineProperty(window, "__cspToken", { value: token, enumerable: false, configurable: false, writable: false });
|
|
11
|
+
|
|
12
|
+
document.getElementById("verify").addEventListener("click", (e) => {
|
|
13
|
+
const bad = [];
|
|
14
|
+
if (!e.isTrusted) bad.push("verify click isTrusted=false (use trusted/CDP input)");
|
|
15
|
+
const val = (document.getElementById("tokenInput").value || "").trim();
|
|
16
|
+
if (val !== token) {
|
|
17
|
+
bad.push(`token mismatch: got "${val}" expected "${token}" — chrome_evaluate must read window.__cspToken under strict CSP`);
|
|
18
|
+
}
|
|
19
|
+
if (bad.length) Challenge.fail(...bad);
|
|
20
|
+
else Challenge.pass("strict CSP: chrome_evaluate read the hidden token via CDP and trusted input submitted it");
|
|
21
|
+
});
|
package/test-suite/manifest.json
CHANGED
|
@@ -1510,7 +1510,7 @@
|
|
|
1510
1510
|
"strict-csp"
|
|
1511
1511
|
],
|
|
1512
1512
|
"notes": [
|
|
1513
|
-
"chrome_snapshot/chrome_evaluate
|
|
1513
|
+
"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."
|
|
1514
1514
|
],
|
|
1515
1515
|
"manualBaseline": "unverified",
|
|
1516
1516
|
"gradeSource": "page"
|
|
@@ -1626,5 +1626,56 @@
|
|
|
1626
1626
|
],
|
|
1627
1627
|
"manualBaseline": "unverified",
|
|
1628
1628
|
"gradeSource": "page"
|
|
1629
|
+
},
|
|
1630
|
+
{
|
|
1631
|
+
"id": "strict-csp-evaluate",
|
|
1632
|
+
"file": "challenges/42-strict-csp-evaluate.html",
|
|
1633
|
+
"category": "csp",
|
|
1634
|
+
"difficulty": "L2",
|
|
1635
|
+
"gate": "core",
|
|
1636
|
+
"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.",
|
|
1637
|
+
"expected": {
|
|
1638
|
+
"synthetic": "FAIL",
|
|
1639
|
+
"trusted": "PASS",
|
|
1640
|
+
"manual": "PASS"
|
|
1641
|
+
},
|
|
1642
|
+
"recipe": [
|
|
1643
|
+
{
|
|
1644
|
+
"tool": "chrome_evaluate",
|
|
1645
|
+
"params": {
|
|
1646
|
+
"expression": "window.__cspToken"
|
|
1647
|
+
}
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
"tool": "chrome_fill",
|
|
1651
|
+
"params": {
|
|
1652
|
+
"selector": "#tokenInput",
|
|
1653
|
+
"value": "$RESULT_OF_chrome_evaluate",
|
|
1654
|
+
"trusted": true
|
|
1655
|
+
}
|
|
1656
|
+
},
|
|
1657
|
+
{
|
|
1658
|
+
"tool": "chrome_click",
|
|
1659
|
+
"params": {
|
|
1660
|
+
"selector": "#verify",
|
|
1661
|
+
"trusted": true
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
],
|
|
1665
|
+
"requires": {
|
|
1666
|
+
"cdp": true
|
|
1667
|
+
},
|
|
1668
|
+
"tags": [
|
|
1669
|
+
"csp",
|
|
1670
|
+
"evaluate",
|
|
1671
|
+
"snapshot",
|
|
1672
|
+
"strict-csp",
|
|
1673
|
+
"cdp-bypass"
|
|
1674
|
+
],
|
|
1675
|
+
"notes": [
|
|
1676
|
+
"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."
|
|
1677
|
+
],
|
|
1678
|
+
"manualBaseline": "unverified",
|
|
1679
|
+
"gradeSource": "page"
|
|
1629
1680
|
}
|
|
1630
1681
|
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Unit harness for the CSP-bypass layer in service_worker.js.
|
|
2
|
+
//
|
|
3
|
+
// The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
|
|
4
|
+
// proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
|
|
5
|
+
// the JS *logic* of the refactor that the bypass depends on:
|
|
6
|
+
// - evaluateInTab: wrapper-string construction, expression/statement fallback, value
|
|
7
|
+
// marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
|
|
8
|
+
// - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
|
|
9
|
+
// all real HELPER_FUNCS serialize+assign without a parse error.
|
|
10
|
+
// - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
|
|
11
|
+
//
|
|
12
|
+
// We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
|
|
13
|
+
// shim that evaluates the expression in a separate "page world" vm context (simulating CDP
|
|
14
|
+
// Runtime.evaluate returnByValue). No browser, no network, no deps.
|
|
15
|
+
|
|
16
|
+
import vm from "node:vm";
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
|
|
21
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
|
|
23
|
+
const src = fs.readFileSync(workerPath, "utf8");
|
|
24
|
+
|
|
25
|
+
let failures = 0;
|
|
26
|
+
let passes = 0;
|
|
27
|
+
function ok(cond, msg) {
|
|
28
|
+
if (cond) { passes++; }
|
|
29
|
+
else { failures++; console.error(` ✗ ${msg}`); }
|
|
30
|
+
}
|
|
31
|
+
async function throwsWith(fn, re, msg) {
|
|
32
|
+
try { await fn(); ok(false, `${msg} (expected throw)`); }
|
|
33
|
+
catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
|
|
37
|
+
const pageGlobals = {
|
|
38
|
+
console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
|
|
39
|
+
Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
|
|
40
|
+
setTimeout, parseInt, parseFloat, isNaN,
|
|
41
|
+
document: {
|
|
42
|
+
title: "page title",
|
|
43
|
+
_present: new Set(),
|
|
44
|
+
querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
pageGlobals.window = pageGlobals;
|
|
48
|
+
pageGlobals.globalThis = pageGlobals;
|
|
49
|
+
const pageWorld = vm.createContext(pageGlobals);
|
|
50
|
+
|
|
51
|
+
// Simulate CDP Runtime.evaluate returnByValue serialization.
|
|
52
|
+
function toCdpResult(v) {
|
|
53
|
+
if (v === undefined) return { result: { type: "undefined" } };
|
|
54
|
+
if (v === null) return { result: { type: "object", subtype: "null", value: null } };
|
|
55
|
+
const t = typeof v;
|
|
56
|
+
if (t === "number" || t === "string" || t === "boolean")
|
|
57
|
+
return { result: { type: t, value: v } };
|
|
58
|
+
// object/array: returnByValue deep-clones JSON-able structures
|
|
59
|
+
return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---- worker sandbox ----
|
|
63
|
+
const noop = () => {};
|
|
64
|
+
const listener = { addListener: noop, removeListener: noop };
|
|
65
|
+
const sandbox = {
|
|
66
|
+
console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
|
|
67
|
+
Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
|
|
68
|
+
setTimeout, clearTimeout,
|
|
69
|
+
setInterval: () => 0,
|
|
70
|
+
clearInterval: noop,
|
|
71
|
+
fetch: async () => { throw new Error("no network in unit test"); },
|
|
72
|
+
navigator: { userAgent: "unit-test" },
|
|
73
|
+
WebSocket: function () {},
|
|
74
|
+
chrome: {
|
|
75
|
+
runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
|
|
76
|
+
alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
|
|
77
|
+
action: { onClicked: listener },
|
|
78
|
+
debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
|
|
79
|
+
scripting: { executeScript: async () => [{ result: undefined }] },
|
|
80
|
+
tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
|
|
81
|
+
windows: { update: async () => {} },
|
|
82
|
+
webNavigation: { onCommitted: listener },
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
sandbox.globalThis = sandbox;
|
|
86
|
+
sandbox.self = sandbox;
|
|
87
|
+
vm.createContext(sandbox);
|
|
88
|
+
vm.runInContext(src, sandbox);
|
|
89
|
+
|
|
90
|
+
// ---- override the page-touching primitives with the page-world shim ----
|
|
91
|
+
sandbox.attachDebugger = async () => ({});
|
|
92
|
+
sandbox.bringToFront = async () => {};
|
|
93
|
+
sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
|
|
94
|
+
sandbox.cdp = async (_tabId, method, params) => {
|
|
95
|
+
if (method !== "Runtime.evaluate") return {};
|
|
96
|
+
try {
|
|
97
|
+
const value = await vm.runInContext(params.expression, pageWorld);
|
|
98
|
+
return toCdpResult(value);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
// Phase-2 of executeInTab: run the injected wrapper func against the page world,
|
|
104
|
+
// where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
|
|
105
|
+
sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
|
|
106
|
+
const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
|
|
107
|
+
const result = await fn(...(args || []));
|
|
108
|
+
return [{ result }];
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const { evaluateInTab, executeInTab, dispatch } = sandbox;
|
|
112
|
+
|
|
113
|
+
async function run() {
|
|
114
|
+
// ===== evaluateInTab: primitives & objects =====
|
|
115
|
+
ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
|
|
116
|
+
ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
|
|
117
|
+
ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
|
|
118
|
+
const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
|
|
119
|
+
ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
|
|
120
|
+
|
|
121
|
+
// ===== value markers =====
|
|
122
|
+
ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
|
|
123
|
+
ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
|
|
124
|
+
ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
|
|
125
|
+
ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
|
|
126
|
+
|
|
127
|
+
// DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
|
|
128
|
+
const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
|
|
129
|
+
ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
|
|
130
|
+
|
|
131
|
+
// ===== statement-form fallback (expression form is a SyntaxError) =====
|
|
132
|
+
// `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
|
|
133
|
+
ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
|
|
134
|
+
ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
|
|
135
|
+
|
|
136
|
+
// ===== error propagation =====
|
|
137
|
+
await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
|
|
138
|
+
|
|
139
|
+
// ===== executeInTab: 2-phase define + invoke =====
|
|
140
|
+
// Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
|
|
141
|
+
const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
|
|
142
|
+
ok(sum === 7, "executeInTab: action runs with args after helper injection");
|
|
143
|
+
|
|
144
|
+
const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
|
|
145
|
+
ok(asyncResult === 42, "executeInTab: async action awaited");
|
|
146
|
+
|
|
147
|
+
await throwsWith(
|
|
148
|
+
() => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
|
|
149
|
+
/action failed/,
|
|
150
|
+
"executeInTab: thrown action error propagates via envelope",
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// ===== page.waitFor (service-worker-side polling) =====
|
|
154
|
+
pageGlobals.document._present.add("#ready");
|
|
155
|
+
const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
|
|
156
|
+
ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
|
|
157
|
+
|
|
158
|
+
const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
|
|
159
|
+
ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
|
|
160
|
+
|
|
161
|
+
await throwsWith(
|
|
162
|
+
() => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
|
|
163
|
+
/Timed out after 120ms/,
|
|
164
|
+
"waitFor: missing selector times out",
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
console.log(`\n${passes} passed, ${failures} failed`);
|
|
168
|
+
if (failures) process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
run().catch((e) => { console.error(e); process.exit(1); });
|