diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index c263772a8550..2669b291cd8d 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -3,6 +3,7 @@ import * as NodeEvents from "node:events"; import * as NodeFSP from "node:fs/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { it } from "@effect/vitest"; import { EnvironmentId, @@ -19,6 +20,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; @@ -1644,7 +1646,7 @@ it.live.each([false, true])( }, saveAs: (path: string) => NodeFSP.copyFile(original, path), suggestedFilename: () => "report.csv", - url: () => "blob:http://localhost:5173/1", + url: () => 'data:text/csv;charset="a,b",a b\'c', }); let offered = yield* Queue.take(viewer.output); while (offered._tag !== "download") offered = yield* Queue.take(viewer.output); @@ -1658,6 +1660,9 @@ it.live.each([false, true])( }); expect(Option.isSome(file) && file.value.fileName).toBe("report.csv"); expect(yield* fs.readFileString(Option.getOrThrow(file).path)).toBe("a,b"); + if ((yield* HostProcess.Platform) !== "win32") { + expect((yield* fs.stat(Option.getOrThrow(file).path)).mode & 0o777).toBe(0o600); + } const status = yield* broker.invoke({ scope, tabId, @@ -1665,7 +1670,11 @@ it.live.each([false, true])( input: {}, }); expect(status.downloads).toEqual([ - expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }), + expect.objectContaining({ + fileName: "report.csv", + sizeBytes: 3, + url: "data:text/csv,…", + }), ]); expect( Option.isNone( @@ -1676,6 +1685,20 @@ it.live.each([false, true])( }), ), ).toBe(true); + if ((yield* HostProcess.Platform) !== "win32") { + const folder = (yield* Path.Path).dirname(Option.getOrThrow(file).path); + yield* fs.chmod(folder, 0o755); + page.emit("download", { + failure: async () => null, + delete: async () => {}, + saveAs: (path: string) => NodeFSP.writeFile(path, "c"), + suggestedFilename: () => "again.csv", + url: () => "https://example.test/again.csv", + }); + let again = yield* Queue.take(viewer.output); + while (again._tag !== "download") again = yield* Queue.take(viewer.output); + expect((yield* fs.stat(folder)).mode & 0o777).toBe(0o700); + } }), ).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 8dbd5310499f..5b386e5f0797 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -795,7 +795,11 @@ const make = Effect.gen(function* () { navigationGenerations.set(request, ++tab.navigationGeneration); clearAbortedNavigation(tab); tab.loading = true; - report(tab, { _tag: "Loading", url: request.url().slice(0, 2048), title: "" }); + report(tab, { + _tag: "Loading", + url: ServerBrowserPage.withoutDataPayload(request.url()).slice(0, 2048), + title: "", + }); }); page.on("load", () => { tab.loading = false; @@ -811,7 +815,7 @@ const make = Effect.gen(function* () { page.on("requestfailed", (request) => { const errorText = request.failure()?.errorText ?? ""; pushBounded(tab.networkEntries, { - url: request.url(), + url: ServerBrowserPage.withoutDataPayload(request.url()), method: request.method(), status: null, failed: true, @@ -844,7 +848,7 @@ const make = Effect.gen(function* () { const { code, description } = ServerBrowserPage.parseNetError(errorText); report(tab, { _tag: "LoadFailed", - url: request.url().slice(0, 2048), + url: ServerBrowserPage.withoutDataPayload(request.url()).slice(0, 2048), title: "", code, description, @@ -852,7 +856,7 @@ const make = Effect.gen(function* () { }); page.on("response", (response) => { pushBounded(tab.networkEntries, { - url: response.url(), + url: ServerBrowserPage.withoutDataPayload(response.url()), method: response.request().method(), status: response.status(), failed: false, @@ -862,7 +866,7 @@ const make = Effect.gen(function* () { page.on("console", (message) => { pushBounded(tab.consoleEntries, { level: message.type(), - text: message.text().slice(0, 2_000), + text: ServerBrowserPage.consoleText(message).slice(0, 2_000), timestamp: new Date().toISOString(), }); }); @@ -932,7 +936,7 @@ const make = Effect.gen(function* () { } report(tab, { _tag: "LoadFailed", - url: url.slice(0, 2048), + url: ServerBrowserPage.withoutDataPayload(url).slice(0, 2048), title: "", code: -3, description: "ERR_ABORTED", @@ -949,8 +953,12 @@ const make = Effect.gen(function* () { let offered: { readonly id: string; readonly fileName: string } | undefined; try { if (await download.failure()) return; - await NodeFSP.mkdir(downloadDir(tab), { recursive: true }); + await NodeFSP.mkdir(downloadDir(tab), { recursive: true, mode: 0o700 }); + // mkdir leaves the mode of a folder that already exists, such as one an older build made. + await NodeFSP.chmod(downloadDir(tab), 0o700); await download.saveAs(path); + // saveAs copies with the process umask; a download should stay readable by its owner only. + await NodeFSP.chmod(path, 0o600); const { size } = await NodeFSP.stat(path); if (tab.closing || size > DOWNLOAD_MAX_BYTES) { await NodeFSP.rm(path, { force: true }); @@ -961,7 +969,7 @@ const make = Effect.gen(function* () { fileName: download.suggestedFilename().slice(0, 255) || "download", path, sizeBytes: size, - url: download.url().slice(0, 2048), + url: ServerBrowserPage.withoutDataPayload(download.url()).slice(0, 2048), completedAt: new Date().toISOString(), }; tab.downloads.push(saved); diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 609f4e67db9d..f59096b9f0a5 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -178,6 +178,64 @@ describe("server browser element refs", () => { expect(cut.slice(-8)).toBe("xxx•••••"); }); + it("shows a data: link or blocked data: navigation by media type only", async () => { + // Chromium keeps raw spaces and quotes in both, so a payload can't be cut at either. + await page.setContent(` + Key + Report + data:text/plain,label + Relative + Spaced + Tabbed + Separated + Typeless + Vertical tab + Backspace + Controlled + Escaped + `); + const tree = String((await takeSnapshot()).accessibilityTree); + expect(tree.match(/\/url: data:text\/plain,…$/gm)).toHaveLength(8); + expect(tree).toContain("/url: data:,…"); + expect(tree).toContain("/url: https://example.com/data:report,2026"); + expect(tree).toMatch(/link "data:text\/plain,label" \[ref=t3-/); + expect(tree).not.toMatch(/PAYLOAD|tail|charset|\u2028/); + expect(tree).toContain('/url: "\\"data:text/plain,relative"'); + }); + + it("redacts a data: link whose scheme a password value would mask", async () => { + await page.setContent(` + + Key`); + const tree = String((await takeSnapshot()).accessibilityTree); + expect(tree).toContain("/url: ••••••••:text/plain,…"); + expect(tree).not.toContain("PAYLOAD"); + }); + + it("shows data: URLs in Chromium's console messages by media type, and page logs as written", async () => { + const logged: Array = []; + page.on("console", (message) => logged.push(ServerBrowserPage.consoleText(message))); + await page.setContent(` + + + `); + // Chromium opens a data: URL whose type is malformed too, and quotes it unchanged. + for (const url of ["data:text/plain,head PAYLOAD'tail x", "data:text/plain charset,PAYLOAD"]) { + const blocked = page.waitForEvent("console"); + await page.evaluate(`location.href = ${JSON.stringify(url)}`); + await blocked; + } + expect(logged).toEqual([ + "Downloaded data:text/plain,example", + "Loading the image 'data:image/png,…", + "Not allowed to navigate top frame to data URL: data:text/plain,…", + "Not allowed to navigate top frame to data URL: data:,…", + ]); + }); + it("rejects refs from another tab", async () => { await page.setContent(""); const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 40665970364d..3be31f3ca5c1 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -18,7 +18,7 @@ import { type PreviewAutomationWaitForInput, } from "@t3tools/contracts"; import { constVoid } from "effect/Function"; -import type { CDPSession, Frame, Locator, Page } from "playwright-core"; +import type { CDPSession, ConsoleMessage, Frame, Locator, Page } from "playwright-core"; import * as NodeCrypto from "node:crypto"; import { BrowserControlInterrupted } from "./SessionControl.ts"; @@ -149,6 +149,54 @@ const SNAPSHOT_SCRIPT = `(() => { })()`; const MASKED_VALUE = "••••••••"; + +/** + * A `data:` URL carries its content, which can be a whole file, and URLs and diagnostics + * reach tool results and provider logs. Keeps only the media type: the payload can hold any + * character and parameters are page-chosen too. + */ +export const withoutDataPayload = (url: string) => { + // Read the scheme as a browser would: it ignores leading controls and spaces, and tabs and + // newlines anywhere. A snapshot's YAML value may be quoted and escaped; an escape that isn't a + // tab or newline still decodes to something that can't start a scheme. + const value = url + .replace(/^["']/, "") + .replace(/\\(x[\da-f]{2}|u[\da-f]{4}|.)/gi, (_escape, code: string) => + code.length > 1 + ? String.fromCharCode(Number.parseInt(code.slice(1), 16)) + : ({ t: "\t", n: "\n", r: "\r", '"': '"', "\\": "\\" }[code] ?? "\u0001"), + ) + .replace(/[\t\n\r]/g, "") + // eslint-disable-next-line no-control-regex -- URL parsing ignores these controls. + .replace(/^[\u0000-\u0020]+/, ""); + const parsed = /^data:([^,;]*)/i.exec(value); + if (!parsed) return url; + const type = parsed[1]!.trim(); + const mediaType = /^[\w.+-]{1,64}\/[\w.+-]{1,64}$/.test(type) ? type : ""; + return `data:${mediaType},…`; +}; + +/** + * Chromium's own console messages, which carry no arguments, quote `data:` URLs whole, as in a + * blocked navigation or a CSP violation. Everything after the first one goes, since Chromium + * accepts any type and a payload can hold any character. What a page logs itself is its own + * text, like anything else it shows, so it stays. + */ +export const consoleText = (message: ConsoleMessage) => + message.args().length > 0 + ? message.text() + : message.text().replace(/(? + tree + .split("\n") + .map((line) => { + const url = /^(\s*- \/url: )(.*)$/s.exec(line); + return url ? url[1] + withoutDataPayload(url[2]!) : line; + }) + .join("\n"); + // The broker waits 15s for the host; a snapshot, retry included, answers well before. const SNAPSHOT_TIMEOUT_MS = 10_000; // How long a frame in another process gets to answer; one that crashed never does. @@ -534,7 +582,10 @@ const capture = async ( const [page, data] = await pageAndScreenshot; if (state.generation !== generation) throw pageChanged(); const mask = (text: string) => (dotted ? text.replace(dotted, MASKED_VALUE) : text); - const accessibilityTree = (dotted ? maskTree(tree, dotted) : tree) + // Redacted before the cut, so a payload crossing it can't leave its start showing, and before + // masking, which could hide the `data:` scheme behind a password value's dots. + const redacted = withoutTreeDataPayloads(tree); + const accessibilityTree = (dotted ? maskTree(redacted, dotted) : redacted) .slice(0, MAX_VISIBLE_TEXT_LENGTH) .replace(/\[ref=((?:f\d+)?e\d+)\]/g, (_match, nativeRef: string) => { const ref = `t3-${generation}-${nativeRef}`; diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 147825ee36e1..caf25aa12e0d 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -202,6 +202,8 @@ export function normalizePreviewUrl(rawUrl: string): string { cause, }); } + // The server preview browser reports page URLs unredacted; a data: URL would put its + // content in tool results and logs. if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new PreviewUrlNormalizationError({ inputLength: rawUrl.length,