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
7 changes: 7 additions & 0 deletions packages/protocol/src/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
ActionResultSchema,
assertAllowedNavigateUrl,
CloseSessionResultSchema,
CompanionApiErrorBodySchema,
CompanionCommandSuccessSchema,
Expand All @@ -24,6 +25,12 @@ import {
} from "./index.js";

describe("RuntimeCommandSchema", () => {
it("re-exports navigate url policy used after click-driven navigation", () => {
expect(() => assertAllowedNavigateUrl("http://127.0.0.1/secret")).toThrow(
"not allowed",
);
});

it("parses a valid navigate command", () => {
const command = NavigateCommandSchema.parse({
action: "navigate",
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ export const McpToolErrorEnvelopeSchema = z

export type McpToolErrorEnvelope = z.infer<typeof McpToolErrorEnvelopeSchema>;

export { assertAllowedNavigateUrl } from "./navigate-url.js";

export function createTraceContext(): TraceContext {
return TraceContextSchema.parse({
traceId: crypto.randomUUID(),
Expand Down
82 changes: 82 additions & 0 deletions packages/protocol/src/navigate-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { assertAllowedNavigateUrl } from "./navigate-url.js";

describe("assertAllowedNavigateUrl", () => {
it("allows public http and https targets", () => {
expect(() =>
assertAllowedNavigateUrl("https://example.com/path"),
).not.toThrow();
expect(() => assertAllowedNavigateUrl("http://example.org")).not.toThrow();
});

it("does not treat public hostnames as IPv6 unique-local prefixes", () => {
expect(() => assertAllowedNavigateUrl("https://fd.com")).not.toThrow();
expect(() =>
assertAllowedNavigateUrl("https://fcbarcelona.com"),
).not.toThrow();
});

it("rejects file and non-http schemes", () => {
expect(() => assertAllowedNavigateUrl("file:///etc/passwd")).toThrow(
"http or https",
);
expect(() => assertAllowedNavigateUrl("javascript:alert(1)")).toThrow(
"http or https",
);
});

it("rejects localhost, trailing-dot localhost, and private networks", () => {
expect(() => assertAllowedNavigateUrl("http://localhost/admin")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://localhost./admin")).toThrow(
"not allowed",
);
expect(() =>
assertAllowedNavigateUrl("http://metadata.google.internal/"),
).toThrow("not allowed");
expect(() =>
assertAllowedNavigateUrl("http://foo.localhost/admin"),
).toThrow("not allowed");
expect(() => assertAllowedNavigateUrl("http://127.0.0.1:8080")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://169.254.169.254/")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://192.168.1.1/")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://10.0.0.5/")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://172.16.0.1/")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://100.64.0.1/")).toThrow(
"not allowed",
);
expect(() =>
assertAllowedNavigateUrl("https://172.32.0.1/not-private"),
).not.toThrow();
});

it("rejects loopback IPv6 including bracketed and v4-mapped forms", () => {
expect(() => assertAllowedNavigateUrl("http://[::1]/")).toThrow(
"not allowed",
);
expect(() =>
assertAllowedNavigateUrl("http://[::ffff:127.0.0.1]/secret"),
).toThrow("not allowed");
expect(() => assertAllowedNavigateUrl("http://[fe80::1]/")).toThrow(
"not allowed",
);
expect(() => assertAllowedNavigateUrl("http://[fc00::1]/")).toThrow(
"not allowed",
);
});

it("rejects unparseable urls", () => {
expect(() => assertAllowedNavigateUrl("not a url")).toThrow("invalid");
});
});
127 changes: 127 additions & 0 deletions packages/protocol/src/navigate-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
const BLOCKED_EXACT_HOSTS = new Set(["localhost", "metadata.google.internal"]);

/**
* Reject navigate/document URLs that can read local files or internal networks.
*
* @example
* assertAllowedNavigateUrl("https://example.com/path");
* // throws for "http://127.0.0.1/secret"
*/
export function assertAllowedNavigateUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error("invalid navigate url");
}

const scheme = parsed.protocol.replace(":", "").toLowerCase();
if (scheme !== "http" && scheme !== "https") {
throw new Error("navigate url must use http or https");
}

const hostname = normalizeHostname(parsed.hostname);
if (isBlockedHostname(hostname)) {
throw new Error("navigate url host is not allowed");
}
}

function normalizeHostname(hostname: string): string {
let host = hostname.toLowerCase();
if (host.startsWith("[") && host.endsWith("]")) {
host = host.slice(1, -1);
}
if (host.endsWith(".")) {
host = host.slice(0, -1);
}
return host;
}

function isBlockedHostname(hostname: string): boolean {
if (BLOCKED_EXACT_HOSTS.has(hostname) || hostname.endsWith(".localhost")) {
return true;
}
if (hostname.includes(":")) {
return isPrivateOrReservedIpv6(hostname);
}
return isPrivateOrReservedIpv4(hostname);
}

function parseIpv4Octets(host: string): number[] | null {
const parts = host.split(".");
if (parts.length !== 4) {
return null;
}
const octets: number[] = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) {
return null;
}
const value = Number(part);
if (value < 0 || value > 255) {
return null;
}
octets.push(value);
}
return octets;
}

function isPrivateOrReservedIpv4(host: string): boolean {
const octets = parseIpv4Octets(host);
if (octets === null) {
return false;
}
const [a, b] = octets;
if (a === undefined) {
return false;
}
if (a === 10 || a === 127 || a === 0) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a === 172 && b !== undefined && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 100 && b !== undefined && b >= 64 && b <= 127) {
return true;
}
return false;
}

function ipv4MappedFromIpv6(host: string): string | null {
const dotted = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(host);
if (dotted?.[1] !== undefined) {
return dotted[1];
}
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
if (hex?.[1] === undefined || hex[2] === undefined) {
return null;
}
const high = Number.parseInt(hex[1], 16);
const low = Number.parseInt(hex[2], 16);
return `${(high >> 8) & 255}.${high & 255}.${(low >> 8) & 255}.${low & 255}`;
}

function isPrivateOrReservedIpv6(host: string): boolean {
if (host === "::1" || host === "0:0:0:0:0:0:0:1") {
return true;
}
const mappedIpv4 = ipv4MappedFromIpv6(host);
if (mappedIpv4 !== null) {
return isPrivateOrReservedIpv4(mappedIpv4);
}
const firstHextet = host.split(":")[0] ?? "";
// Unique local fc00::/7 and link-local fe80::/10.
if (firstHextet.startsWith("fc") || firstHextet.startsWith("fd")) {
return true;
}
if (/^fe[89ab]/i.test(firstHextet)) {
return true;
}
return false;
}
71 changes: 71 additions & 0 deletions packages/runtime/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,11 @@ function createMockBrowserTree() {
})),
};
const contextClose = vi.fn().mockResolvedValue(undefined);
const mockRoute = vi.fn().mockResolvedValue(undefined);
const context = {
newPage: vi.fn(async () => page),
close: contextClose,
route: mockRoute,
};
const browserClose = vi.fn().mockResolvedValue(undefined);
const browser = {
Expand All @@ -82,6 +84,8 @@ function createMockBrowserTree() {
mockClick,
mockFill,
mockContent,
mockUrl,
mockRoute,
contextClose,
browserClose,
};
Expand Down Expand Up @@ -153,6 +157,73 @@ describe("BrowserRuntime", () => {
await rt.shutdown();
});

it("installs a session request guard on createSession", async () => {
const { browser, mockRoute } = createMockBrowserTree();
mockChromiumLaunch.mockResolvedValueOnce(browser);

const rt = new BrowserRuntime({ headless: true });
await rt.createSession();
expect(mockRoute).toHaveBeenCalledWith("**/*", expect.any(Function));
await rt.shutdown();
});

it("rejects click navigation onto a loopback url and restores the prior page", async () => {
const tree = createMockBrowserTree();
let currentUrl = "https://page.example/path";
tree.mockUrl.mockImplementation(() => currentUrl);
tree.mockClick.mockImplementation(async () => {
currentUrl = "http://127.0.0.1:65500/secret";
});
tree.mockGoto.mockImplementation(async (url: string) => {
currentUrl = url;
});
mockChromiumLaunch.mockResolvedValueOnce(tree.browser);

const rt = new BrowserRuntime({ headless: true });
try {
const { sessionId } = await rt.createSession();
await expect(
rt.click({
action: "click",
sessionId,
selector: "#to-internal",
}),
).rejects.toMatchObject({
code: "COMMAND_FAILED",
message: expect.stringContaining("127.0.0.1:65500"),
});
expect(tree.mockGoto).toHaveBeenCalledWith("https://page.example/path", {
waitUntil: "domcontentloaded",
});
expect(currentUrl).toBe("https://page.example/path");
} finally {
await rt.shutdown();
}
});

it("does not snapshot a loopback page and restores about:blank", async () => {
const tree = createMockBrowserTree();
tree.mockUrl.mockReturnValue("http://127.0.0.1/secret");
mockChromiumLaunch.mockResolvedValueOnce(tree.browser);

const rt = new BrowserRuntime({ headless: true });
try {
const { sessionId } = await rt.createSession();
await expect(
rt.snapshot({ action: "snapshot", sessionId }),
).rejects.toMatchObject({
code: "COMMAND_FAILED",
message: expect.stringContaining("127.0.0.1"),
});
expect(tree.mockContent).not.toHaveBeenCalled();
expect(tree.mockGoto).toHaveBeenCalledWith("about:blank", {
waitUntil: "domcontentloaded",
});
} finally {
await rt.shutdown();
}
});

it("clicks and types", async () => {
const { mockClick, mockFill, browser } = createMockBrowserTree();
mockChromiumLaunch.mockResolvedValueOnce(browser);
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
} from "@webchain/protocol";
import type { Browser, BrowserContext, Page } from "playwright";
import { chromium, webkit } from "playwright";
import {
enforceAllowedPageUrl,
installSessionRequestGuard,
} from "./navigation-guard.js";
import {
mapCommandFailure,
mapPlaywrightLaunchError,
Expand Down Expand Up @@ -74,6 +78,7 @@ export class BrowserRuntime {
const context = await browser.newContext();

try {
await installSessionRequestGuard(context);
const page = await context.newPage();
const sessionId = randomUUID();
const pageId = randomUUID();
Expand Down Expand Up @@ -114,6 +119,7 @@ export class BrowserRuntime {
const session = this.getSession(command.sessionId);
try {
const page = session.page;
await enforceAllowedPageUrl(page, "about:blank");
const html = await page.content();
/** Covers sync throws (some Playwright APIs throw before returning a Promise). */
const safe = async <T>(run: () => Promise<T>): Promise<T | undefined> => {
Expand Down Expand Up @@ -165,8 +171,10 @@ export class BrowserRuntime {

async click(command: ClickCommand): Promise<ActionResult> {
const session = this.getSession(command.sessionId);
const previousUrl = session.page.url();
try {
await session.page.locator(command.selector).first().click();
await enforceAllowedPageUrl(session.page, previousUrl);
} catch (error) {
throw mapCommandFailure(error);
}
Expand All @@ -180,8 +188,10 @@ export class BrowserRuntime {

async type(command: TypeCommand): Promise<ActionResult> {
const session = this.getSession(command.sessionId);
const previousUrl = session.page.url();
try {
await session.page.locator(command.selector).first().fill(command.text);
await enforceAllowedPageUrl(session.page, previousUrl);
} catch (error) {
throw mapCommandFailure(error);
}
Expand Down
Loading
Loading