diff options
| author | Igor Soarez <igor@soarez.org> | 2026-11-10 19:17:57 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-11-10 19:22:56 +0200 |
| commit | cf47a164991bf5588fd2b41ce244f55d900c1167 (patch) | |
| tree | d2fffa70d1fce92be5aaa2124ed77a6be2e0c3c0 /test-suite | |
| parent | b8b63ea4d1bad9c4bbac6b05b03cce5f2cfa3f94 (diff) | |
extension: snapshot sandboxed pages over CDP, never return empty text
On a page served with CSP `sandbox` and no allow-scripts, such as
raw.githubusercontent.com, chrome.scripting.executeScript runs nothing and
resolves with a null result instead of throwing. The access-denied CDP
fallback never ran, page.snapshot returned undefined, and chrome_snapshot
answered with {type: "text"} and no text, because JSON.stringify(undefined)
is undefined. Pi saved that block in the session, and every later turn
failed in token estimation with "Cannot read properties of undefined
(reading 'length')", whatever the provider.
When scripting resolves without an envelope, snapshot and inspect now
redo the injection and the call over CDP Runtime.evaluate, which such pages
still allow, and raise an error if that also returns nothing. On the pi
side, safeJson always returns a string, and an empty snapshot or inspect
result is a tool error rather than a text block without text.
sandboxed-snapshot.test.mjs drives page.snapshot and page.inspect through a
page that runs no extension script. It fails on b8b63ea.
Diffstat (limited to 'test-suite')
| -rw-r--r-- | test-suite/unit/sandboxed-snapshot.test.mjs | 108 |
1 files changed, 108 insertions, 0 deletions
diff --git a/test-suite/unit/sandboxed-snapshot.test.mjs b/test-suite/unit/sandboxed-snapshot.test.mjs new file mode 100644 index 0000000..9774f21 --- /dev/null +++ b/test-suite/unit/sandboxed-snapshot.test.mjs @@ -0,0 +1,108 @@ +// chrome_snapshot and chrome_inspect on a page served with CSP `sandbox` (no allow-scripts), such as +// raw.githubusercontent.com. There chrome.scripting.executeScript resolves with a null result +// instead of throwing, so the access-denied CDP fallback never ran and the snapshot came back +// undefined. The worker now redoes the injection over CDP Runtime.evaluate, which such pages allow. +import assert from "node:assert/strict"; +import fs from "node:fs"; +import vm from "node:vm"; +import { test } from "node:test"; + +const workerUrl = new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url); +const src = fs.readFileSync(workerUrl, "utf8"); + +// Stands in for snapshot_injected.js when the worker reads it from the package for CDP injection. +const INJECTED_STUB = ` +globalThis.__piChromeSnapshotPage = async (...args) => ({ mode: args[4], title: "raw page" }); +globalThis.__piChromeInspectTarget = async (uid) => ({ target: { uid } }); +`; + +function loadWorker({ scriptingRuns, cdpReturnsNothing = false }) { + const pageWorld = vm.createContext({ Promise, Error, String, JSON }); + pageWorld.globalThis = pageWorld; + const calls = { scripting: 0, cdp: 0 }; + const noop = () => {}; + const listener = { addListener: noop, removeListener: noop }; + const sandbox = { + console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean, + Error, TypeError, Map, Set, Symbol, structuredClone, + setTimeout, clearTimeout, setInterval: () => 0, clearInterval: noop, + fetch: async (url) => { + assert.match(String(url), /snapshot_injected\.js$/); + return { ok: true, text: async () => INJECTED_STUB }; + }, + navigator: { userAgent: "unit-test" }, + WebSocket: function () {}, + chrome: { + runtime: { + id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), + getURL: (file) => `chrome-extension://unittestextension/${file}`, + 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: { + // A sandboxed document runs no extension script and reports a null result. + executeScript: async ({ files, func, args }) => { + calls.scripting++; + if (!scriptingRuns) return [{ frameId: 0, result: null }]; + if (files) { + vm.runInContext(INJECTED_STUB, pageWorld); + return [{ frameId: 0, result: undefined }]; + } + const fn = vm.runInContext(`(${func.toString()})`, pageWorld); + return [{ frameId: 0, result: await fn(...(args || [])) }]; + }, + }, + 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); + sandbox.attachDebugger = async () => ({}); + sandbox.bringToFront = async () => {}; + sandbox.getTabByParams = async () => ({ id: 7, windowId: 1, url: "https://raw.githubusercontent.com/o/r/main/f.swift" }); + sandbox.cdp = async (_tabId, method, params) => { + assert.equal(method, "Runtime.evaluate"); + calls.cdp++; + if (cdpReturnsNothing) return { result: { type: "undefined" } }; + // returnByValue: JSON-able values come back as `value`; functions and undefined only as a type. + const value = await vm.runInContext(params.expression, pageWorld); + const json = JSON.stringify(value); + return json === undefined + ? { result: { type: typeof value } } + : { result: { type: "object", value: JSON.parse(json) } }; + }; + return { dispatch: sandbox.dispatch, calls }; +} + +test("snapshot of a sandboxed page falls back to CDP", async () => { + const { dispatch, calls } = loadWorker({ scriptingRuns: false }); + const snapshot = await dispatch("page.snapshot", { mode: "text" }); + assert.equal(snapshot?.title, "raw page"); + assert.equal(snapshot?.mode, "text"); + assert.equal(calls.scripting, 2, "scripting is tried first for both the injection and the call"); + assert.equal(calls.cdp, 2, "both are redone over CDP"); +}); + +test("inspect of a sandboxed page falls back to CDP", async () => { + const { dispatch } = loadWorker({ scriptingRuns: false }); + const inspect = await dispatch("page.inspect", { uid: "e12" }); + assert.equal(inspect?.target?.uid, "e12"); +}); + +test("snapshot of an ordinary page does not touch CDP", async () => { + const { dispatch, calls } = loadWorker({ scriptingRuns: true }); + const snapshot = await dispatch("page.snapshot", { mode: "auto" }); + assert.equal(snapshot?.title, "raw page"); + assert.equal(calls.cdp, 0); +}); + +test("a page that runs neither path reports an error instead of an empty snapshot", async () => { + const { dispatch } = loadWorker({ scriptingRuns: false, cdpReturnsNothing: true }); + await assert.rejects(dispatch("page.snapshot", { mode: "text" }), /snapshot script returned no result in tab 7 \(https:\/\/raw\.githubusercontent\.com/); +}); |
