mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Keep browser evaluation working on strict-policy pages without shadowing page globals (#8973)
* fix(desktop): run browser_evaluate on pages whose CSP omits 'unsafe-eval' * test(desktop): browser_evaluate on pages that allow and forbid eval * test(desktop): e2e opens a page under a CSP without 'unsafe-eval' and evaluates on it * fix(desktop): preserve page globals in browser evaluation Define the supplied function outside every wrapper binding so strict-policy evaluation keeps the original page scope. Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com> --------- Co-authored-by: Abdulhakeem Mustapha <abdulhakeemmustapha@gmail.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Abdulhakeem Mustapha
parent
daf26892d3
commit
751dc82e6c
@@ -213,8 +213,18 @@ async function settleDesktopStartup(page) {
|
||||
}
|
||||
}
|
||||
|
||||
const CSP_PAGE_PATH = "/csp-no-eval";
|
||||
|
||||
async function startTargetPage() {
|
||||
const server = createServer((_request, response) => {
|
||||
const server = createServer((request, response) => {
|
||||
if (request.url === CSP_PAGE_PATH) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"content-security-policy": "default-src 'self'; script-src 'self'",
|
||||
});
|
||||
response.end("<!doctype html><title>CSP page</title><h1>CSP page heading</h1>");
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
||||
response.end(`<!doctype html>
|
||||
<html>
|
||||
@@ -351,6 +361,42 @@ async function waitForGuestActiveElement(client, browserId, elementId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A page whose Content Security Policy omits 'unsafe-eval' refuses eval(); the tools must still work.
|
||||
async function verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl) {
|
||||
const created = await callBrowserTool(client, "browser_new_tab", {
|
||||
url: new URL(CSP_PAGE_PATH, targetUrl).href,
|
||||
});
|
||||
const browserId = created.browserId;
|
||||
assert(typeof browserId === "string", "browser_new_tab returned no browserId for the CSP page");
|
||||
try {
|
||||
await callBrowserTool(client, "browser_wait", {
|
||||
browserId,
|
||||
text: "CSP page heading",
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
const title = await callBrowserTool(client, "browser_evaluate", {
|
||||
browserId,
|
||||
function: "() => document.title",
|
||||
});
|
||||
assert(
|
||||
JSON.parse(title.resultJson) === "CSP page",
|
||||
`browser_evaluate on a page without 'unsafe-eval' returned ${title.resultJson}`,
|
||||
);
|
||||
// The control: the page itself really does refuse eval().
|
||||
const probe = await callBrowserTool(client, "browser_evaluate", {
|
||||
browserId,
|
||||
function:
|
||||
'() => { try { (0, eval)("1"); return "eval allowed"; } catch (error) { return error.message; } }',
|
||||
});
|
||||
assert(
|
||||
/Content Security Policy/.test(JSON.parse(probe.resultJson)),
|
||||
`The CSP page did not refuse eval(): ${probe.resultJson}`,
|
||||
);
|
||||
} finally {
|
||||
await callBrowserTool(client, "browser_close_tab", { browserId });
|
||||
}
|
||||
}
|
||||
|
||||
async function createCallerAgent(daemonPort, workspaceId = workspaceIds[0]) {
|
||||
const transport = new StreamableHTTPClientTransport(
|
||||
new URL(`http://127.0.0.1:${daemonPort}/mcp/agents`),
|
||||
@@ -736,6 +782,8 @@ async function runRegression({
|
||||
document.body.appendChild(overlayRoot);
|
||||
});
|
||||
|
||||
await verifyEvaluateOnPageWithoutUnsafeEval(client, targetUrl);
|
||||
|
||||
const created = await callBrowserTool(client, "browser_new_tab", { url: targetUrl });
|
||||
const browserId = created.browserId;
|
||||
assert(typeof browserId === "string", "browser_new_tab returned no browserId");
|
||||
@@ -1168,6 +1216,7 @@ async function runRegression({
|
||||
snapshot: "passed",
|
||||
click: "passed",
|
||||
localPageSelectors: "passed",
|
||||
evaluateWithoutUnsafeEval: "passed",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -455,6 +455,34 @@ function browserWithTimingEntries(entries: { resource: unknown[]; navigation: un
|
||||
return browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the script the host injects into a page the way Chromium runs it on a page whose Content
|
||||
* Security Policy omits 'unsafe-eval': injected code runs, but code in the page cannot turn a
|
||||
* string into code (eval, new Function). Node's vm refuses string code generation the same way.
|
||||
*/
|
||||
function pageRunningInjectedScripts(input: {
|
||||
allowEval: boolean;
|
||||
globals?: Record<string, unknown>;
|
||||
}) {
|
||||
const page = createContext(
|
||||
{ document: { title: "Fixture" }, ...input.globals },
|
||||
{ codeGeneration: { strings: input.allowEval, wasm: input.allowEval } },
|
||||
);
|
||||
return (script: string): unknown => runInContext(script, page);
|
||||
}
|
||||
|
||||
/** A browser whose tab runs the evaluate script in `page`; its other scripts keep the fake answers. */
|
||||
function browserEvaluatingIn(page: (script: string) => unknown) {
|
||||
const browser = new BrowserAutomationHarness();
|
||||
const fakeAnswer = browser.tab.executeJavaScript.bind(browser.tab);
|
||||
browser.tab.executeJavaScript = async (script: string) => {
|
||||
if (!script.includes("__PASEO_BROWSER_EVALUATE__")) return fakeAnswer(script);
|
||||
browser.tab.scripts.push(script);
|
||||
return page(script);
|
||||
};
|
||||
return browser;
|
||||
}
|
||||
|
||||
function requireSnapshotRefs(result: Awaited<ReturnType<BrowserAutomationHarness["snapshot"]>>) {
|
||||
expect(result).toEqual({
|
||||
requestId: "req-snapshot",
|
||||
@@ -1645,6 +1673,99 @@ describe("executeAutomationCommand", () => {
|
||||
expect(containsScript(browser.tab, "resultJson.slice(0, 79000)")).toBe(true);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{ page: "allows eval", allowEval: true },
|
||||
{ page: "forbids eval (its Content Security Policy omits 'unsafe-eval')", allowEval: false },
|
||||
])("evaluate on a page that $page", ({ allowEval }) => {
|
||||
const evaluate = (browser: BrowserAutomationHarness, source: string) =>
|
||||
browser.execute({
|
||||
command: "evaluate",
|
||||
args: { browserId: BROWSER_A, function: source },
|
||||
});
|
||||
|
||||
test("runs the function and returns its result as JSON", async () => {
|
||||
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
|
||||
|
||||
expect(await evaluate(browser, "() => document.title")).toEqual({
|
||||
requestId: "req-evaluate",
|
||||
ok: true,
|
||||
result: {
|
||||
command: "evaluate",
|
||||
browserId: BROWSER_A,
|
||||
resultJson: '"Fixture"',
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("awaits an async function", async () => {
|
||||
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
|
||||
|
||||
const result = await evaluate(
|
||||
browser,
|
||||
"async () => { await Promise.resolve(); return 6 * 7; }",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, result: { resultJson: "42" } });
|
||||
});
|
||||
|
||||
test("resolves names against the page's globals, never the wrapper's own", async () => {
|
||||
const globals = {
|
||||
element: "page element",
|
||||
args: "page args",
|
||||
value: "page value",
|
||||
resultJson: "page json",
|
||||
userFunction: "page function",
|
||||
__paseoEvaluate: "page wrapper",
|
||||
readFunction: "page reader",
|
||||
};
|
||||
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval, globals }));
|
||||
|
||||
const result = await evaluate(
|
||||
browser,
|
||||
"() => [element, args, value, resultJson, userFunction, __paseoEvaluate, readFunction]",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
result: { resultJson: JSON.stringify(Object.values(globals)) },
|
||||
});
|
||||
});
|
||||
|
||||
test("reports what the function throws", async () => {
|
||||
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
|
||||
|
||||
expect(await evaluate(browser, "() => { throw new Error('boom'); }")).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "browser_unknown_error", message: "boom" },
|
||||
});
|
||||
});
|
||||
|
||||
test("reports an expression that is not a function", async () => {
|
||||
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
|
||||
|
||||
expect(await evaluate(browser, "document.title")).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "browser_unknown_error",
|
||||
message: "browser_evaluate input must evaluate to a function.",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("reports a syntax error with the compiler's own message, without touching the page", async () => {
|
||||
const browser = browserEvaluatingIn(pageRunningInjectedScripts({ allowEval }));
|
||||
|
||||
const result = await evaluate(browser, "() => {");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "browser_unknown_error", message: expect.stringMatching(/^Unexpected /) },
|
||||
});
|
||||
expect(containsScript(browser.tab, "__PASEO_BROWSER_EVALUATE__")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("screenshot captures the painted viewport", async () => {
|
||||
const browser = new BrowserAutomationHarness();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isAbsolute, relative, resolve as resolvePath } from "node:path";
|
||||
import { Script } from "node:vm";
|
||||
|
||||
import type {
|
||||
BrowserAutomationCommand,
|
||||
@@ -849,6 +850,11 @@ async function executeEvaluate(
|
||||
elementExpression = expression;
|
||||
}
|
||||
|
||||
const syntaxError = functionSyntaxError(functionSource);
|
||||
if (syntaxError !== null) {
|
||||
return fail(requestId, "browser_unknown_error", capEvaluateErrorMessage(syntaxError));
|
||||
}
|
||||
|
||||
let rawResult: unknown;
|
||||
try {
|
||||
rawResult = await target.contents.executeJavaScript(
|
||||
@@ -1550,14 +1556,36 @@ function capEvaluateErrorMessage(message: string): string {
|
||||
: message.slice(0, MAX_EVALUATE_ERROR_MESSAGE_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* The syntax error in the agent's function, or null. It is compiled here because a script that
|
||||
* fails to compile in the page only reports "Script failed to execute", and a typo deserves the
|
||||
* compiler's own message. Compiling runs nothing.
|
||||
*/
|
||||
function functionSyntaxError(functionSource: string): string | null {
|
||||
try {
|
||||
void new Script(`(${functionSource})`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page script that runs the agent's function and returns its result as bounded JSON.
|
||||
*
|
||||
* The function goes into the script as code, not as a string for the page to eval(): a page whose
|
||||
* Content Security Policy omits 'unsafe-eval' refuses eval() but still runs what the host injects.
|
||||
* The function-producing thunk is an argument defined outside the wrapper, so its names resolve
|
||||
* against the page's globals without capturing any wrapper parameters or locals.
|
||||
*/
|
||||
function buildEvaluateScript(
|
||||
functionSource: string,
|
||||
elementExpression: string | undefined,
|
||||
): string {
|
||||
return String.raw`(async () => {
|
||||
return String.raw`(async (readFunction) => {
|
||||
const __PASEO_BROWSER_EVALUATE__ = true;
|
||||
try {
|
||||
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
|
||||
const userFunction = readFunction();
|
||||
if (typeof userFunction !== 'function') {
|
||||
throw new Error('browser_evaluate input must evaluate to a function.');
|
||||
}
|
||||
@@ -1582,7 +1610,7 @@ function buildEvaluateScript(
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
})()`;
|
||||
})(() => (${functionSource}))`;
|
||||
}
|
||||
|
||||
async function focusKeypressTarget(
|
||||
|
||||
Reference in New Issue
Block a user