diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-31 14:19:04 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-31 14:19:04 -0400 |
| commit | d21ebf7b96c90943ab48ffcd6d5b02c6f7438c04 (patch) | |
| tree | 00a005deb29fd4071c0d67be8e533bab5972d850 /extensions | |
| parent | 0fc5b8950601a36dfffdc8f7163bb12fb94f353e (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 'extensions')
3 files changed, 133 insertions, 110 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index b6ef0fc..55e9152 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.15.28", + "version": "0.15.29", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index f269afa..850784f 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -217,6 +217,38 @@ async function cdp(tabId, method, params) { } } +// cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP +// Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to +// the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'` +// without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is +// attached first. Returns the raw CDP result ({ result, exceptionDetails }). +async function cdpEval(tabId, expression, opts) { + await attachDebugger(tabId); + return cdp(tabId, "Runtime.evaluate", { + expression, + returnByValue: true, + awaitPromise: true, + userGesture: true, + ...(opts || {}), + }); +} + +function cdpExceptionText(details) { + if (!details) return ""; + return String( + details.exception?.description || + details.exception?.value || + details.text || + "", + ); +} + +function cdpIsSyntaxError(details) { + if (!details) return false; + const className = String(details.exception?.className || ""); + return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details)); +} + // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab. async function resolveTargetInTab(tabId, params) { const results = await chrome.scripting.executeScript({ @@ -739,8 +771,29 @@ async function dispatch(action, params) { return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]); case "page.network.get": return executeInTab(params, getNetworkRequest, [params.requestId]); - case "page.waitFor": - return executeInTab(params, waitForPage, [params.kind, params.value, params.timeoutMs || 10000, params.intervalMs || 250]); + case "page.waitFor": { + // Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling + // loop in-page with new Function() for expression checks, which fails under strict CSP. + const tab = await getTabByParams(params); + if (params.foreground) await bringToFront(tab); + const timeoutMs = params.timeoutMs || 10000; + const intervalMs = params.intervalMs || 250; + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + let ok = false; + try { + const expr = params.kind === "selector" + ? `!!document.querySelector(${JSON.stringify(params.value)})` + : params.value; + ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false })); + } catch { + ok = false; + } + if (ok) return { elapsedMs: Date.now() - started }; + await sleep(intervalMs); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`); + } case "page.probe": // Lightweight capability probe for /chrome-doctor. Runs in MAIN world. return executeInTab(params, probePage, []); @@ -847,25 +900,33 @@ const HELPER_FUNCS = [ async function executeInTab(params, func, args) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); - const helperSource = HELPER_FUNCS.map((helper) => helper.toString()).join("\n"); + + // Phase 1: define the helpers and the action function as page globals via CDP + // Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the + // root cause of snapshot/click/etc silently failing on `script-src 'self'` sites. + // Each helper is a named function declaration, assigned to window.<name> so the action + // (which references helpers by bare name) resolves them as globals at call time. + const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n"); + const actionAssign = `window.__piAction=(${func.toString()})`; + const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`); + if (defineRes.exceptionDetails) { + throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`); + } + + // Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is + // injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome + // serialize the invocation args. The wrapper references window.__piAction defined above. const results = await chrome.scripting.executeScript({ target: { tabId: tab.id }, world: "MAIN", - func: async (helperSource, source, invocationArgs) => { + func: async (invocationArgs) => { try { - // Helpers are plain function declarations; injecting them via Function constructor avoids - // running through `eval` (which is restricted under strict CSP) and keeps them isolated. - new Function(helperSource).call(globalThis); - // The action itself is reconstructed from its source text. We use `new Function` rather - // than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`) - // CSPs that are common on production sites. - const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis); - return { ok: true, value: await injected(...invocationArgs) }; + return { ok: true, value: await window.__piAction(...invocationArgs) }; } catch (error) { return { ok: false, error: error?.stack || error?.message || String(error) }; } }, - args: [helperSource, func.toString(), args], + args: [args || []], }); const first = results?.[0]; if (first?.error) { @@ -879,72 +940,54 @@ async function executeInTab(params, func, args) { return envelope?.value; } -// Dedicated executor for page.evaluate. Doesn't go through the helper-source injection chain; -// that chain was the root cause of `chrome_evaluate` silently returning null on pages with strict -// CSP. We build a single Function in MAIN world and invoke it directly. +// Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated +// expression so we can return rich markers for values that don't survive returnByValue +// (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields +// are non-enumerable. Kept as a standalone function so it stays editable/lintable. +function piEvalStringify(v) { + if (v === undefined) return { kind: "undefined" }; + if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) }; + if (typeof v === "symbol") return { kind: "symbol", description: v.description }; + if (typeof v === "bigint") return { kind: "bigint", value: v.toString() }; + if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack }; + // DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable + // properties, so JSON.stringify yields `{}`. Expand the fields explicitly. + if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) || + (typeof DOMRect !== "undefined" && v instanceof DOMRect) || + (v && typeof v === "object" && typeof v.toJSON === "function" && + typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) { + 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 }; + } + return v; +} + +// Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not +// subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on +// pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`). async function evaluateInTab(params) { const tab = await getTabByParams(params); if (params.foreground) await bringToFront(tab); const expression = String(params.expression ?? ""); - const awaitPromise = params.awaitPromise !== false; - const results = await chrome.scripting.executeScript({ - target: { tabId: tab.id }, - world: "MAIN", - func: async (expression, awaitPromise) => { - const stringify = (v) => { - if (v === undefined) return { kind: "undefined" }; - if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) }; - if (typeof v === "symbol") return { kind: "symbol", description: v.description }; - if (typeof v === "bigint") return { kind: "bigint", value: v.toString() }; - if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack }; - // DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable - // properties, so JSON.stringify yields `{}`. Expand the fields explicitly. - if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) || - (typeof DOMRect !== "undefined" && v instanceof DOMRect) || - (v && typeof v === "object" && typeof v.toJSON === "function" && - typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) { - 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 }; - } - return v; - }; - // Compile via the Function constructor. We try expression form first so callers can pass - // `1+1` or `document.title` without a `return`; if that's a SyntaxError we retry with the - // statement form so callers can use multi-statement bodies (loops, var decls, etc). - const compile = (src) => { - try { - return { fn: new Function(`return (async () => (${src}))();`), mode: "expression" }; - } catch (e1) { - if (e1 && e1.name === "SyntaxError") { - try { - return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" }; - } catch (e2) { - throw e2; - } - } - throw e1; - } - }; - try { - const { fn } = compile(expression); - const value = await fn.call(globalThis); - const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value; - return { ok: true, value: stringify(resolved) }; - } catch (error) { - return { ok: false, error: error?.stack || error?.message || String(error) }; - } - }, - args: [expression, awaitPromise], - }); - const first = results?.[0]; - if (first?.error) { - const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error)); - throw new Error(`chrome_evaluate failed: ${message}`); - } - const envelope = first?.result; - if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world"); - if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed"); - const v = envelope.value; - // Unwrap special markers from MAIN world + const stringifySrc = `(${piEvalStringify.toString()})`; + // Wrap the user expression so the result is run through piEvalStringify in-page before it + // crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title` + // work without `return`); on a SyntaxError fall back to statement form for multi-statement + // bodies (loops, var decls, etc), matching the previous new Function() two-form behavior. + const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`; + const exprForm = `(async () => (${expression}))()`; + const stmtForm = `(async () => { ${expression} })()`; + + let res = await cdpEval(tab.id, buildWrapper(exprForm)); + if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) { + res = await cdpEval(tab.id, buildWrapper(stmtForm)); + } + if (res.exceptionDetails) { + throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`); + } + const result = res.result; + if (!result || result.type === "undefined") return undefined; + const v = result.value; + // Unwrap special markers produced by piEvalStringify. if (v && typeof v === "object" && !Array.isArray(v)) { if (v.kind === "undefined") return undefined; if (v.kind === "function") return `[Function: ${v.source}]`; @@ -964,29 +1007,23 @@ async function withOptionalSnapshot(params, actionFn) { return result; } -// One-shot init script registry, scoped per tab. The script source is injected at -// document_start of the next committed navigation in that tab, in MAIN world, then cleared. -const initScriptIds = new Map(); +// One-shot init script registry, scoped per tab. The source is registered with CDP +// Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN +// world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was +// blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters +// after load, so only the intended navigation receives the script. +const initScriptIds = new Map(); // tabId -> CDP script identifier async function registerInitScript(tabId, source) { - initScriptIds.set(tabId, source); + await attachDebugger(tabId); + await cdp(tabId, "Page.enable", {}).catch(() => undefined); + const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source }); + if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier); } async function unregisterInitScript(tabId) { + const identifier = initScriptIds.get(tabId); + if (identifier === undefined) return; initScriptIds.delete(tabId); -} - -if (chrome.webNavigation && chrome.webNavigation.onCommitted) { - chrome.webNavigation.onCommitted.addListener((details) => { - if (details.frameId !== 0) return; - const source = initScriptIds.get(details.tabId); - if (!source) return; - chrome.scripting.executeScript({ - target: { tabId: details.tabId, frameIds: [0] }, - world: "MAIN", - injectImmediately: true, - func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } }, - args: [source], - }).catch(() => undefined); - }); + await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined); } // Always inject early console/network capture at document_start on every navigation. @@ -2076,20 +2113,6 @@ function getNetworkRequest(requestId) { return request; } -async function waitForPage(kind, value, timeoutMs, intervalMs) { - const started = Date.now(); - while (Date.now() - started < timeoutMs) { - let ok = false; - if (kind === "selector") ok = Boolean(document.querySelector(value)); - else { - try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; } - } - if (ok) return { elapsedMs: Date.now() - started }; - await new Promise((resolve) => setTimeout(resolve, intervalMs)); - } - throw new Error(`Timed out after ${timeoutMs}ms waiting for ${kind}: ${value}`); -} - function normalizeKey(key) { const table = { enter: "Enter", diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 04a1810..21dec6d 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -665,7 +665,7 @@ Chrome control is available through the chrome_* tools via a companion Chrome ex Capability model (important): - 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. - 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. -- \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via the **Function constructor**, which requires \`'unsafe-eval'\` in the page CSP. Pages with strict CSP (e.g. github.com, many bank/SaaS apps) will throw \`EvalError: ... 'unsafe-eval' is not an allowed source of script\` and chrome_snapshot will return empty. On those pages, drive the page with \`chrome_screenshot\` + viewport-coordinate \`chrome_click\`/\`chrome_type\`/\`chrome_key\`. \`chrome_navigate\`, \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input all keep working under any CSP. +- \`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. - 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. Usage rules: |
