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
18 changes: 18 additions & 0 deletions packages/protocol/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,24 @@ import {
} from "./index.js";

describe("RuntimeCommandSchema", () => {
it("rejects file and internal navigate targets", () => {
expect(() =>
NavigateCommandSchema.parse({
action: "navigate",
sessionId: "session-1",
url: "file:///etc/passwd",
}),
).toThrow();

expect(() =>
NavigateCommandSchema.parse({
action: "navigate",
sessionId: "session-1",
url: "http://127.0.0.1:3000",
}),
).toThrow();
});

it("parses a valid navigate command", () => {
const command = NavigateCommandSchema.parse({
action: "navigate",
Expand Down
18 changes: 17 additions & 1 deletion packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from "zod";
import { assertAllowedNavigateUrl } from "./navigate-url.js";

export const RUNTIME_ACTIONS = [
"navigate",
Expand All @@ -13,7 +14,20 @@ export type RuntimeAction = (typeof RUNTIME_ACTIONS)[number];
export const NavigateCommandSchema = z.object({
action: z.literal("navigate"),
sessionId: z.string().min(1),
url: z.string().url(),
url: z
.string()
.url()
.superRefine((value, ctx) => {
try {
assertAllowedNavigateUrl(value);
} catch (error) {
ctx.addIssue({
code: "custom",
message:
error instanceof Error ? error.message : "invalid navigate url",
});
}
}),
});

export const SnapshotCommandSchema = z.object({
Expand Down Expand Up @@ -253,6 +267,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
33 changes: 33 additions & 0 deletions packages/protocol/src/navigate-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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("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 and private networks", () => {
expect(() => assertAllowedNavigateUrl("http://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",
);
});
});
81 changes: 81 additions & 0 deletions packages/protocol/src/navigate-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const BLOCKED_HOSTNAMES = new Set(["localhost", "metadata.google.internal"]);

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 === 10 || a === 127 || a === 0) {
return true;
}
if (a === 169 && b === 254) {
return true;
}
if (a === 172 && b >= 16 && b <= 31) {
return true;
}
if (a === 192 && b === 168) {
return true;
}
if (a === 100 && b >= 64 && b <= 127) {
return true;
}
return false;
}

function isPrivateOrReservedIpv6(host: string): boolean {
const normalized = host.toLowerCase();
if (normalized === "::1") {
return true;
}
if (normalized.startsWith("fc") || normalized.startsWith("fd")) {
return true;
}
if (normalized.startsWith("fe80:")) {
return true;
}
return false;
}

/** Reject navigate targets that could read local files or reach internal networks. */
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(":", "");
if (scheme !== "http" && scheme !== "https") {
throw new Error("navigate url must use http or https");
}

const hostname = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(hostname) || hostname.endsWith(".localhost")) {
throw new Error("navigate url host is not allowed");
}
if (isPrivateOrReservedIpv4(hostname) || isPrivateOrReservedIpv6(hostname)) {
throw new Error("navigate url host is not allowed");
}
}
2 changes: 2 additions & 0 deletions packages/runtime/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import {
type ActionResult,
assertAllowedNavigateUrl,
type ClickCommand,
type CloseSessionCommand,
type CloseSessionResult,
Expand Down Expand Up @@ -98,6 +99,7 @@ export class BrowserRuntime {
async navigate(command: NavigateCommand): Promise<ActionResult> {
const session = this.getSession(command.sessionId);
try {
assertAllowedNavigateUrl(command.url);
await session.page.goto(command.url, { waitUntil: "domcontentloaded" });
} catch (error) {
throw mapCommandFailure(error);
Expand Down
Loading