diff --git a/docs/testing.md b/docs/testing.md index 804ecb635e..0d2caad5fa 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -93,6 +93,12 @@ installed Chrome-family browser. An optional script argument selects an already installed package's `scripts/rea.mjs` entry point for packaged-artifact checks. The complete `verify:browser` lane includes these same checks. +After building, `verify:browser:dom` checks empty and HTML-whitespace form +destinations against a native Chrome DOM-property oracle through CLI and stdio +MCP. It requires `REA_BROWSER_EXECUTABLE` and accepts an optional installed REA +entrypoint. The full browser lane includes the same public-adapter assertions +before other fixtures navigate the selected page. + `verify:browser:scripts` checks active script capture → exact-byte export → existing static JavaScript analysis through CLI and stdio MCP, including manifest readback, competing query variants, and resolved relative imports. diff --git a/package.json b/package.json index cfc5e283ea..906eb218f2 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,7 @@ "verify:ghidra:cross-format": "npm run build:cached && node scripts/verify-real-ghidra.mjs --cross-format", "verify:ghidra:windows": "npm run build:cached && npm run build:ghidra-windows-fixture && node scripts/verify-real-ghidra-windows.mjs", "verify:browser": "npm run build:cached && node scripts/verify-real-browser.mjs", + "verify:browser:dom": "node scripts/verify-browser-dom-destinations.mjs", "verify:browser:network": "npm run build:cached && node scripts/verify-browser-network.mjs", "verify:electron": "npm run build:cached && node scripts/verify-real-electron.mjs", "verify:inspector": "npm run build:cached && node scripts/verify-real-javascript-runtime-observation.mjs", diff --git a/scripts/fixtures/browser-verifier-site.mjs b/scripts/fixtures/browser-verifier-site.mjs index 3dbf8f5183..30a7b36e76 100644 --- a/scripts/fixtures/browser-verifier-site.mjs +++ b/scripts/fixtures/browser-verifier-site.mjs @@ -57,7 +57,7 @@ export async function startBrowserVerifierSite() { "rea-app-cookie=app-fixture; Path=/app; SameSite=Lax", ]); response.end( - 'REA browser verifier

Browser evidence

Guide
', + 'REA browser verifier

Browser evidence

Guide
', ); }); const webSockets = new WebSocketServer({ noServer: true }); diff --git a/scripts/lib/browser-dom-destinations-e2e.mjs b/scripts/lib/browser-dom-destinations-e2e.mjs new file mode 100644 index 0000000000..2a7006b948 --- /dev/null +++ b/scripts/lib/browser-dom-destinations-e2e.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +import { parseEvidence } from "../../dist/domain/evidence.js"; +import { assertFormDestinations } from "./browser-verifier-assertions.mjs"; +import { mcpTextValue } from "./mcp-verifier-results.mjs"; + +/** Verify the real fixture's form destinations through CLI and stdio MCP. */ +export async function verifyBrowserDomDestinations( + attachment, + entrypoint = fileURLToPath(new URL("../rea.mjs", import.meta.url)), + documentUrl, +) { + const env = { ...process.env, REA_LOG_LEVEL: "silent" }; + const { stdout } = await promisify(execFile)( + process.execPath, + [ + entrypoint, + "inspect-web-page", + attachment.cdp_endpoint, + attachment.target_id, + "--observation-ms", + "100", + "--json", + ], + { env, timeout: 60000, maxBuffer: 32 * 1024 * 1024 }, + ); + assertFormDestinations( + parseEvidence(JSON.parse(stdout)).normalized_result, + documentUrl, + ); + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint, "mcp"], + env, + stderr: "pipe", + }); + const client = new Client({ name: "browser-dom-destinations", version: "1" }); + try { + await client.connect(transport); + const result = await client.callTool({ + name: "inspect_web_page", + arguments: { ...attachment, observation_ms: 100 }, + }); + assert.notEqual(result.isError, true, mcpTextValue(result)); + assertFormDestinations( + parseEvidence(JSON.parse(mcpTextValue(result)).evidence) + .normalized_result, + documentUrl, + ); + return { cli: true, stdio_mcp: true, current_document_destinations: 4 }; + } finally { + await client.close(); + await transport.close(); + } +} diff --git a/scripts/lib/browser-verifier-assertions.mjs b/scripts/lib/browser-verifier-assertions.mjs index ad22ff7122..7c26e1d987 100644 --- a/scripts/lib/browser-verifier-assertions.mjs +++ b/scripts/lib/browser-verifier-assertions.mjs @@ -87,6 +87,7 @@ function assertObservationPrivacy(result) { } function assertObservationMetadata(result, origin) { + assertFormDestinations(result); if ( !result.metadata.dom_urls.some( ({ attribute, url }) => @@ -121,6 +122,23 @@ function assertObservationMetadata(result, origin) { throw new Error("Real Chrome safe response metadata was missing"); } +/** Assert the fixture's empty form destinations retain the current document URL. */ +export function assertFormDestinations( + result, + documentUrl = result.target.url, +) { + const destinations = result.metadata.dom_urls.filter( + ({ attribute }) => attribute === "action" || attribute === "formaction", + ); + if ( + destinations.length !== 4 || + destinations.some(({ url }) => url !== documentUrl) + ) + throw new Error( + `Real Chrome form destinations did not use the current document URL: ${JSON.stringify({ expected: documentUrl, target: result.target.url, destinations })}`, + ); +} + /** Assert real-browser static bundle and source-map findings. */ export function assertBundleAnalysis(result) { if ( diff --git a/scripts/verify-browser-dom-destinations.mjs b/scripts/verify-browser-dom-destinations.mjs new file mode 100644 index 0000000000..7390caf11a --- /dev/null +++ b/scripts/verify-browser-dom-destinations.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { chromium } from "playwright-core"; +import { startBrowserVerifierSite } from "./fixtures/browser-verifier-site.mjs"; +import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs"; + +const executable = process.env.REA_BROWSER_EXECUTABLE; +if (!executable) + throw new Error("verify:browser:dom requires REA_BROWSER_EXECUTABLE"); +const profile = await mkdtemp(join(tmpdir(), "rea-browser-dom-")); +const site = await startBrowserVerifierSite(); +let context; +try { + context = await chromium.launchPersistentContext(profile, { + executablePath: executable, + headless: true, + args: ["--remote-debugging-port=0"], + }); + const page = context.pages()[0] ?? (await context.newPage()); + await page.goto(`${site.origin}/app?selected=fixture#section`); + const native = await page.evaluate(() => + [ + ...document.querySelectorAll("form,button[formaction],input[formaction]"), + ].map((element) => + element.tagName === "FORM" ? element.action : element.formAction, + ), + ); + assert.deepEqual(native, Array(4).fill(page.url())); + const session = await context.newCDPSession(page); + const { targetInfo } = await session.send("Target.getTargetInfo"); + await session.detach(); + const [port] = ( + await readFile(join(profile, "DevToolsActivePort"), "utf8") + ).split("\n"); + const result = await verifyBrowserDomDestinations( + { + cdp_endpoint: `http://127.0.0.1:${port}`, + target_id: targetInfo.targetId, + }, + process.argv[2], + page.url(), + ); + process.stdout.write( + `${JSON.stringify({ ...result, browser: context.browser().version(), native_oracle: true, verified: true })}\n`, + ); +} finally { + await context?.close(); + await site.close(); + await rm(profile, { recursive: true, force: true }); +} diff --git a/scripts/verify-real-browser.mjs b/scripts/verify-real-browser.mjs index 0d7e33286d..e68e5b15ba 100644 --- a/scripts/verify-real-browser.mjs +++ b/scripts/verify-real-browser.mjs @@ -37,6 +37,7 @@ import { verifyLargeScreenshotE2e } from "./lib/browser-screenshot-e2e.mjs"; import { verifyPopupEventCoverage } from "./lib/browser-popup-e2e.mjs"; import { verifyBrowserNetworkEvidence } from "./lib/browser-network-e2e.mjs"; import { verifyBrowserScriptExport } from "./lib/browser-script-export-e2e.mjs"; +import { verifyBrowserDomDestinations } from "./lib/browser-dom-destinations-e2e.mjs"; const REAL_BROWSER_STARTUP_TIMEOUT_MS = 60_000; const SCENARIO_SECRET_VALUE = "rea-browser-verifier-secret"; @@ -98,6 +99,10 @@ try { ); if (!observed.ok) throw observed.error; assertObservation(observed.value, site.origin); + const domDestinations = await verifyBrowserDomDestinations({ + cdp_endpoint: endpoint, + target_id: target, + }); const serialized = JSON.stringify(observed.value); if ( !observed.value.target.url.includes("startup=browser-secret-value") || @@ -352,6 +357,7 @@ try { popupEvents, networkEvidence, scriptExport, + domDestinations, browserScenarioCli: true, browserScenarioAttachCleanup: "disconnected-external", browserScenarioLaunchCleanup: "terminated-owned-process", diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index d7ffd3e1cb..7b3dc1868b 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -113,3 +113,81 @@ describe("DOM metadata document base URLs", () => { expect(result.agentHints).toEqual([]); }); }); + +describe("empty form destinations with a document base URL", () => { + it.each([ + { nodeName: "FORM", attribute: "action" }, + { nodeName: "BUTTON", attribute: "formaction" }, + { nodeName: "INPUT", attribute: "formaction" }, + ])( + "resolves an empty $nodeName $attribute to the document", + ({ nodeName, attribute }) => { + const pageUrl = `${documentUrl}?selected=fixture#section`; + const baseUrl = `${origin}/assets/`; + const value = snapshot(baseUrl, "/assets/", pageUrl, ""); + value.strings[6] = nodeName; + value.strings[14] = attribute; + value.strings[15] = ""; + const result = capture(value); + expect(result.urls[3]).toMatchObject({ + attribute, + url: pageUrl, + destination_scope: "approved", + }); + expect(result.urls[1]?.url).toBe(baseUrl); + }, + ); + + it("keeps an empty form action approved when the document base is foreign", () => { + const foreign = "https://cdn.example.test/assets/"; + const value = snapshot(foreign, foreign); + value.strings[15] = ""; + expect(capture(value).urls[3]).toMatchObject({ + url: documentUrl, + destination_scope: "approved", + }); + }); + + it.each([" ", "\t\r\n\f"])( + "resolves an HTML-whitespace-only form action to the document: %j", + (action) => { + const baseUrl = `${origin}/assets/`; + const value = snapshot(baseUrl); + value.strings[15] = action; + expect(capture(value).urls[3]?.url).toBe(documentUrl); + }, + ); + + it.each(["BUTTON", "INPUT"])( + "retains document-base resolution for a whitespace-only %s formaction", + (nodeName) => { + const baseUrl = `${origin}/assets/`; + const value = snapshot(baseUrl); + value.strings[6] = nodeName; + value.strings[14] = "formaction"; + value.strings[15] = " "; + expect(capture(value).urls[3]?.url).toBe(baseUrl); + }, + ); + + it.each([ + { value: "\u000b", suffix: "" }, + { value: "\u00a0", suffix: "%C2%A0" }, + ])( + "does not treat non-HTML whitespace as an empty form action: $value", + ({ value: action, suffix }) => { + const baseUrl = `${origin}/assets/`; + const value = snapshot(baseUrl); + value.strings[15] = action; + expect(capture(value).urls[3]?.url).toBe(`${baseUrl}${suffix}`); + }, + ); + + it("does not apply form semantics to an action attribute on another element", () => { + const baseUrl = `${origin}/assets/`; + const value = snapshot(baseUrl); + value.strings[6] = "DIV"; + value.strings[15] = ""; + expect(capture(value).urls[3]?.url).toBe(baseUrl); + }); +}); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 53c608053a..95749beeb0 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -194,6 +194,7 @@ export const captureDom = ( attributes: attributeIndexes, // The BASE element itself resolves against the document fallback URL. baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl, + documentUrl, nodeIndex, nodeName, allowedOrigins, @@ -363,6 +364,7 @@ interface DomMetadataOptions { readonly strings: readonly string[]; readonly attributes: readonly number[]; readonly baseUrl: string; + readonly documentUrl: string; readonly nodeIndex: number; readonly nodeName: string; readonly allowedOrigins: ReadonlySet; @@ -374,8 +376,15 @@ const domMetadata = ( readonly urls: WebPageInspection["metadata"]["dom_urls"]; readonly agentHints: WebPageInspection["metadata"]["agent_hints"]; } => { - const { strings, attributes, baseUrl, nodeIndex, nodeName, allowedOrigins } = - options; + const { + strings, + attributes, + baseUrl, + documentUrl, + nodeIndex, + nodeName, + allowedOrigins, + } = options; const pairs = new Map(); for (let index = 0; index + 1 < attributes.length; index += 2) { const name = indexedString(strings, attributes[index]).toLowerCase(); @@ -386,7 +395,21 @@ const domMetadata = ( for (const attribute of domUrlAttributes) { const value = pairs.get(attribute); if (value === undefined) continue; - const destination = domDestination(value, baseUrl, allowedOrigins); + const tagName = nodeName.toLowerCase(); + // Chrome strips HTML whitespace for FORM.action, while submit controls + // use the document URL only for an exactly empty formaction attribute. + const usesDocumentUrl = + (tagName === "form" && + attribute === "action" && + /^[\t\n\f\r ]*$/u.test(value)) || + ((tagName === "button" || tagName === "input") && + attribute === "formaction" && + value === ""); + const destination = domDestination( + usesDocumentUrl ? documentUrl : value, + baseUrl, + allowedOrigins, + ); urls.push({ node_index: nodeIndex, attribute, diff --git a/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts b/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts index 8dda46cf9e..8b040144ad 100644 --- a/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts +++ b/src/domain/javascriptSemanticAnalysis.member_mutations.test.ts @@ -115,3 +115,25 @@ describe("JavaScript semantic values for properties unaffected by a mutation", ( ).toBe("unknown"); }); }); + +describe("property mutations through TypeScript satisfies aliases", () => { + it.each([ + 'const shared = { mode: "initial" }; const alias = shared satisfies { mode: string }; alias.mode = "updated"; return shared.mode;', + 'const shared = { nested: { mode: "initial" } }; const alias = shared satisfies { nested: { mode: string } }; alias.nested.mode = "updated"; return shared.nested.mode;', + 'const shared = { mode: "initial" }; const alias = (shared satisfies { mode: string }) as { mode: string }; delete alias.mode; return shared.mode;', + "const shared = [1]; const alias = shared satisfies number[]; alias[0]++; return shared[0];", + ])("keeps the mutated value unknown: %s", (body) => { + expect(resultValue(body)?.status).toBe("unknown"); + }); + + it("retains the untouched property through a satisfies alias", () => { + expect( + resultValue(` + const shared = { mode: "initial", token: "TOKEN" }; + const alias = shared satisfies { mode: string; token: string }; + alias.mode = "updated"; + return shared.token; + `), + ).toEqual({ status: "literal", value: "TOKEN" }); + }); +}); diff --git a/src/domain/javascriptSemanticMemberMutations.ts b/src/domain/javascriptSemanticMemberMutations.ts index 4991f23e7e..e168b20468 100644 --- a/src/domain/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascriptSemanticMemberMutations.ts @@ -125,6 +125,7 @@ const referencedValues = ( if ( t.isTSAsExpression(node) || t.isTSTypeAssertion(node) || + t.isTSSatisfiesExpression(node) || t.isTSNonNullExpression(node) ) return [{ node: node.expression, path }];