Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/runtime/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ function createMockBrowserTree() {
mockClick,
mockFill,
mockContent,
mockUrl,
mockEvalAnchors,
contextClose,
browserClose,
};
Expand Down Expand Up @@ -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(
'<html><body><a href="https://alice:s3cret@page.example/x">x</a></body></html>',
);
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);
Expand Down
48 changes: 28 additions & 20 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<SnapshotResult> {
Expand Down Expand Up @@ -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) {
Expand All @@ -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<ActionResult> {
Expand All @@ -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(
Expand Down Expand Up @@ -247,6 +244,17 @@ export {
WebchainRuntimeError,
} from "./runtime-error.js";

async function actionResult(
sessionId: string,
page: Page,
): Promise<ActionResult> {
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);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/runtime/src/runtime-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime/src/runtime-error.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 },
);
}
83 changes: 83 additions & 0 deletions packages/runtime/src/url-redact.test.ts
Original file line number Diff line number Diff line change
@@ -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(
'<a href="https://alice:pw-in-href@example.org/path">l</a>',
);
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 = '<a href="https://alice:pw-in-href@example.org/path">link</a>';
expect(stripUserinfoFromText(html)).toBe(
'<a href="https://example.org/path">link</a>',
);
});

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/"],
});
});
});
70 changes: 70 additions & 0 deletions packages/runtime/src/url-redact.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
out[key] = stripUserinfoDeep(child);
}
return out;
}
return value;
}
Loading