From 9cda68dc8465f556755bab21e6e7f52084930e45 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 11:10:27 +0000 Subject: [PATCH] fix(runtime): block file and internal navigate targets Restrict navigate URLs to public http/https hosts to prevent local file reads and SSRF via snapshot exfiltration. Co-authored-by: esadrianno --- packages/protocol/src/index.test.ts | 18 +++++ packages/protocol/src/index.ts | 18 ++++- packages/protocol/src/navigate-url.test.ts | 33 +++++++++ packages/protocol/src/navigate-url.ts | 81 ++++++++++++++++++++++ packages/runtime/src/index.ts | 2 + 5 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/src/navigate-url.test.ts create mode 100644 packages/protocol/src/navigate-url.ts diff --git a/packages/protocol/src/index.test.ts b/packages/protocol/src/index.test.ts index 48d3c81..140ce1c 100644 --- a/packages/protocol/src/index.test.ts +++ b/packages/protocol/src/index.test.ts @@ -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", diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index a1f2328..81e3fd1 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { assertAllowedNavigateUrl } from "./navigate-url.js"; export const RUNTIME_ACTIONS = [ "navigate", @@ -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({ @@ -253,6 +267,8 @@ export const McpToolErrorEnvelopeSchema = z export type McpToolErrorEnvelope = z.infer; +export { assertAllowedNavigateUrl } from "./navigate-url.js"; + export function createTraceContext(): TraceContext { return TraceContextSchema.parse({ traceId: crypto.randomUUID(), diff --git a/packages/protocol/src/navigate-url.test.ts b/packages/protocol/src/navigate-url.test.ts new file mode 100644 index 0000000..26177ba --- /dev/null +++ b/packages/protocol/src/navigate-url.test.ts @@ -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", + ); + }); +}); diff --git a/packages/protocol/src/navigate-url.ts b/packages/protocol/src/navigate-url.ts new file mode 100644 index 0000000..daf689f --- /dev/null +++ b/packages/protocol/src/navigate-url.ts @@ -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"); + } +} diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6e1c945..ca0a107 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,7 @@ import { randomUUID } from "node:crypto"; import { type ActionResult, + assertAllowedNavigateUrl, type ClickCommand, type CloseSessionCommand, type CloseSessionResult, @@ -98,6 +99,7 @@ export class BrowserRuntime { async navigate(command: NavigateCommand): Promise { const session = this.getSession(command.sessionId); try { + assertAllowedNavigateUrl(command.url); await session.page.goto(command.url, { waitUntil: "domcontentloaded" }); } catch (error) { throw mapCommandFailure(error);