diff --git a/packages/runtime/src/index.test.ts b/packages/runtime/src/index.test.ts index 0b0c708..1a6e1fb 100644 --- a/packages/runtime/src/index.test.ts +++ b/packages/runtime/src/index.test.ts @@ -82,6 +82,8 @@ function createMockBrowserTree() { mockClick, mockFill, mockContent, + mockUrl, + mockEvalAnchors, contextClose, browserClose, }; @@ -153,6 +155,46 @@ describe("BrowserRuntime", () => { await rt.shutdown(); }); + it("redacts URL userinfo from navigate and snapshot outputs", async () => { + const { browser, mockUrl, mockContent, mockEvalAnchors } = + createMockBrowserTree(); + mockUrl.mockReturnValue("https://alice:s3cret@page.example/path"); + mockContent.mockResolvedValue( + 'x', + ); + mockEvalAnchors.mockImplementation( + async ( + _selector: string, + pageFunction: ( + anchors: { href: string; textContent: string }[], + max: number, + ) => unknown, + max: number, + ) => + pageFunction( + [{ href: "https://alice:s3cret@page.example/x", textContent: "x" }], + max, + ), + ); + mockChromiumLaunch.mockResolvedValueOnce(browser); + + const rt = new BrowserRuntime({ headless: true }); + const { sessionId } = await rt.createSession(); + const nav = await rt.navigate({ + action: "navigate", + sessionId, + url: "https://alice:s3cret@example.com/", + }); + expect(nav.url).toBe("https://page.example/path"); + expect(nav.url).not.toContain("s3cret"); + + const snap = await rt.snapshot({ action: "snapshot", sessionId }); + expect(snap.url).toBe("https://page.example/path"); + expect(snap.htmlSnippet).not.toContain("s3cret"); + expect(snap.links?.[0]?.href).toBe("https://page.example/x"); + await rt.shutdown(); + }); + it("clicks and types", async () => { const { mockClick, mockFill, browser } = createMockBrowserTree(); mockChromiumLaunch.mockResolvedValueOnce(browser); diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e1c945..8b14eec 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -19,6 +19,12 @@ import { WebchainRuntimeError, } from "./runtime-error.js"; import { extractLandmarks, extractPageLinks } from "./snapshot-helpers.js"; +import { + stripUserinfoDeep, + stripUserinfoFromLinks, + stripUserinfoFromText, + stripUserinfoFromUrl, +} from "./url-redact.js"; export interface BrowserRuntimeOptions { headless?: boolean; @@ -103,11 +109,7 @@ export class BrowserRuntime { throw mapCommandFailure(error); } - return { - sessionId: command.sessionId, - url: session.page.url(), - title: await session.page.title(), - }; + return actionResult(command.sessionId, session.page); } async snapshot(command: SnapshotCommand): Promise { @@ -150,12 +152,15 @@ export class BrowserRuntime { } return { sessionId: command.sessionId, - url: page.url(), + url: stripUserinfoFromUrl(page.url()), title: await page.title(), - htmlSnippet: summarizeHtml(html), - domSummary, - accessibilityTree: accessibilityJson, - links, + htmlSnippet: stripUserinfoFromText(summarizeHtml(html)), + domSummary: + domSummary === undefined + ? undefined + : stripUserinfoFromText(domSummary), + accessibilityTree: stripUserinfoDeep(accessibilityJson), + links: links === undefined ? undefined : stripUserinfoFromLinks(links), landmarks, }; } catch (error) { @@ -171,11 +176,7 @@ export class BrowserRuntime { throw mapCommandFailure(error); } - return { - sessionId: command.sessionId, - url: session.page.url(), - title: await session.page.title(), - }; + return actionResult(command.sessionId, session.page); } async type(command: TypeCommand): Promise { @@ -186,11 +187,7 @@ export class BrowserRuntime { throw mapCommandFailure(error); } - return { - sessionId: command.sessionId, - url: session.page.url(), - title: await session.page.title(), - }; + return actionResult(command.sessionId, session.page); } async closeSession( @@ -247,6 +244,17 @@ export { WebchainRuntimeError, } from "./runtime-error.js"; +async function actionResult( + sessionId: string, + page: Page, +): Promise { + return { + sessionId, + url: stripUserinfoFromUrl(page.url()), + title: await page.title(), + }; +} + export function summarizeHtml(html: string, limit = 1600) { return html.replace(/\s+/g, " ").trim().slice(0, limit); } diff --git a/packages/runtime/src/runtime-error.test.ts b/packages/runtime/src/runtime-error.test.ts index 3896bbe..1bcef48 100644 --- a/packages/runtime/src/runtime-error.test.ts +++ b/packages/runtime/src/runtime-error.test.ts @@ -39,6 +39,15 @@ describe("mapCommandFailure", () => { expect(err.code).toBe("COMMAND_FAILED"); expect(err.message).toContain("timed out"); }); + + it("redacts URL userinfo from Playwright error messages", () => { + const err = mapCommandFailure( + new Error("net::ERR_FAILED at https://alice:s3cret@127.0.0.1/x"), + ); + expect(err.code).toBe("COMMAND_FAILED"); + expect(err.message).not.toContain("s3cret"); + expect(err.message).toContain("https://127.0.0.1/x"); + }); }); describe("isExecutableMissingMessage", () => { diff --git a/packages/runtime/src/runtime-error.ts b/packages/runtime/src/runtime-error.ts index 95371ef..b814aaf 100644 --- a/packages/runtime/src/runtime-error.ts +++ b/packages/runtime/src/runtime-error.ts @@ -1,4 +1,5 @@ import type { RuntimeErrorCode } from "@webchain/protocol"; +import { stripUserinfoFromText } from "./url-redact.js"; const INSTALL_HINT = "Playwright browser binaries are missing. Run: pnpm --filter @webchain/runtime exec playwright install chromium"; @@ -57,10 +58,11 @@ export function mapCommandFailure(error: unknown): WebchainRuntimeError { } const message = error instanceof Error ? error.message : String(error); + const redacted = stripUserinfoFromText(message); return new WebchainRuntimeError( "COMMAND_FAILED", - message || "Command failed.", + redacted || "Command failed.", { cause: error }, ); } diff --git a/packages/runtime/src/url-redact.test.ts b/packages/runtime/src/url-redact.test.ts new file mode 100644 index 0000000..7ea6047 --- /dev/null +++ b/packages/runtime/src/url-redact.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { + stripUserinfoDeep, + stripUserinfoFromLinks, + stripUserinfoFromText, + stripUserinfoFromUrl, +} from "./url-redact.js"; + +describe("stripUserinfoFromUrl", () => { + it("removes user and password from http(s) URLs", () => { + expect( + stripUserinfoFromUrl("https://alice:s3cret@example.com/path?q=1"), + ).toBe("https://example.com/path?q=1"); + }); + + it("removes username-only userinfo", () => { + expect(stripUserinfoFromUrl("https://alice@example.com/")).toBe( + "https://example.com/", + ); + }); + + it("leaves URLs without userinfo unchanged", () => { + expect(stripUserinfoFromUrl("https://example.com/x")).toBe( + "https://example.com/x", + ); + }); + + it("omits data: payloads so embedded credentials cannot hide in page.url()", () => { + const dataUrl = + "data:text/html," + + encodeURIComponent( + 'l', + ); + const redacted = stripUserinfoFromUrl(dataUrl); + expect(redacted).toBe("data:text/html,"); + expect(redacted).not.toContain("pw-in-href"); + }); + + it("falls back to text stripping for non-URL strings", () => { + expect( + stripUserinfoFromUrl("see https://bob:pw@example.net/a and more"), + ).toBe("see https://example.net/a and more"); + }); +}); + +describe("stripUserinfoFromText", () => { + it("redacts credentials in HTML hrefs", () => { + const html = 'link'; + expect(stripUserinfoFromText(html)).toBe( + 'link', + ); + }); + + it("does not treat mailto addresses as userinfo URLs", () => { + expect(stripUserinfoFromText("contact mailto:user@example.com")).toBe( + "contact mailto:user@example.com", + ); + }); +}); + +describe("stripUserinfoFromLinks", () => { + it("redacts href and preserves link text", () => { + expect( + stripUserinfoFromLinks([ + { href: "https://u:p@example.com/x", text: "x" }, + ]), + ).toEqual([{ href: "https://example.com/x", text: "x" }]); + }); +}); + +describe("stripUserinfoDeep", () => { + it("redacts nested strings", () => { + expect( + stripUserinfoDeep({ + url: "https://u:p@example.com/", + items: ["https://a:b@example.net/"], + }), + ).toEqual({ + url: "https://example.com/", + items: ["https://example.net/"], + }); + }); +}); diff --git a/packages/runtime/src/url-redact.ts b/packages/runtime/src/url-redact.ts new file mode 100644 index 0000000..19456db --- /dev/null +++ b/packages/runtime/src/url-redact.ts @@ -0,0 +1,70 @@ +/** + * Strip URL userinfo so basic-auth credentials are not echoed to companion/MCP. + * Playwright `page.url()` and `HTMLAnchorElement.href` preserve `user:pass@`. + */ + +/** Matches `scheme://userinfo@` including `user@host` and `user:pass@host`. */ +const USERINFO_IN_ABSOLUTE_URL = + /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)([^/\s"'<>@]+@)/g; + +/** + * Remove embedded credentials from a single URL. + * + * @example + * stripUserinfoFromUrl("https://alice:pw@example.com/x") + * // => "https://example.com/x" + */ +export function stripUserinfoFromUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.protocol === "data:") { + // data: URLs are the document; echoing the payload repeats href userinfo + // in percent-encoded form (not matched by the userinfo regex). + const comma = url.indexOf(","); + const withoutPayload = comma === -1 ? url : url.slice(0, comma + 1); + return stripUserinfoFromText(withoutPayload); + } + if (parsed.username !== "" || parsed.password !== "") { + parsed.username = ""; + parsed.password = ""; + } + return stripUserinfoFromText(parsed.href); + } catch { + return stripUserinfoFromText(url); + } +} + +/** + * Remove `user:pass@` (or `user@`) from any absolute URLs inside free-form text. + */ +export function stripUserinfoFromText(text: string): string { + return text.replace(USERINFO_IN_ABSOLUTE_URL, "$1"); +} + +/** Redact `href` on extracted snapshot links. */ +export function stripUserinfoFromLinks( + links: { href: string; text: string }[], +): { href: string; text: string }[] { + return links.map((link) => ({ + ...link, + href: stripUserinfoFromUrl(link.href), + })); +} + +/** Walk JSON-like snapshot trees and redact URLs in string leaves. */ +export function stripUserinfoDeep(value: unknown): unknown { + if (typeof value === "string") { + return stripUserinfoFromText(value); + } + if (Array.isArray(value)) { + return value.map(stripUserinfoDeep); + } + if (value !== null && typeof value === "object") { + const out: Record = {}; + for (const [key, child] of Object.entries(value)) { + out[key] = stripUserinfoDeep(child); + } + return out; + } + return value; +}