diff --git a/packages/cli/src/utils/chromiumUnsafePorts.test.ts b/packages/cli/src/utils/chromiumUnsafePorts.test.ts new file mode 100644 index 0000000000..90e50ab4b5 --- /dev/null +++ b/packages/cli/src/utils/chromiumUnsafePorts.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { CHROMIUM_UNSAFE_PORTS, isChromiumUnsafePort } from "./chromiumUnsafePorts.js"; + +describe("isChromiumUnsafePort", () => { + it("recognizes Chromium's restricted PasswordServer port", () => { + expect(CHROMIUM_UNSAFE_PORTS.has(3659)).toBe(true); + expect(isChromiumUnsafePort(3659)).toBe(true); + }); + + it("allows a representative ephemeral port", () => { + expect(isChromiumUnsafePort(49_152)).toBe(false); + }); +}); diff --git a/packages/cli/src/utils/chromiumUnsafePorts.ts b/packages/cli/src/utils/chromiumUnsafePorts.ts new file mode 100644 index 0000000000..0382f3a051 --- /dev/null +++ b/packages/cli/src/utils/chromiumUnsafePorts.ts @@ -0,0 +1,15 @@ +/** + * Chromium's restricted-port list. Keep this synchronized with `kRestrictedPorts`: + * https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/base/port_util.cc + */ +export const CHROMIUM_UNSAFE_PORTS: ReadonlySet = new Set([ + 0, 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, + 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, + 512, 513, 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, + 995, 1719, 1720, 1723, 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, + 6697, 10080, +]); + +export function isChromiumUnsafePort(port: number): boolean { + return CHROMIUM_UNSAFE_PORTS.has(port); +} diff --git a/packages/cli/src/utils/staticProjectServer.test.ts b/packages/cli/src/utils/staticProjectServer.test.ts index 830012eea8..e7228bcfde 100644 --- a/packages/cli/src/utils/staticProjectServer.test.ts +++ b/packages/cli/src/utils/staticProjectServer.test.ts @@ -1,8 +1,42 @@ import { afterEach, describe, expect, it } from "vitest"; +import { EventEmitter } from "node:events"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js"; +import { + listenOnChromiumSafeEphemeralPort, + serveStaticProjectHtml, + type StaticProjectServer, +} from "./staticProjectServer.js"; + +class FakeServer extends EventEmitter { + private currentPort: number | undefined; + readonly listenCalls: number[] = []; + closeCalls = 0; + + constructor(private readonly assignedPorts: readonly number[]) { + super(); + } + + listen(port: number, host: string, callback: () => void): this { + expect(host).toBe("127.0.0.1"); + this.listenCalls.push(port); + this.currentPort = this.assignedPorts[this.listenCalls.length - 1]; + queueMicrotask(callback); + return this; + } + + address(): { port: number } | null { + return this.currentPort ? { port: this.currentPort } : null; + } + + close(callback?: () => void): this { + this.closeCalls += 1; + this.currentPort = undefined; + queueMicrotask(() => callback?.()); + return this; + } +} let server: StaticProjectServer | undefined; let dir: string | undefined; @@ -21,6 +55,38 @@ async function serveWith(bytes: Buffer): Promise<{ url: string }> { return { url: server.url }; } +describe("listenOnChromiumSafeEphemeralPort", () => { + it("closes and rebinds when the OS assigns a Chromium-unsafe port", async () => { + const fake = new FakeServer([3659, 49_152]); + + await expect(listenOnChromiumSafeEphemeralPort(fake, "bind failed")).resolves.toBe(49_152); + expect(fake.listenCalls).toEqual([0, 0]); + expect(fake.closeCalls).toBe(1); + expect(fake.listenerCount("error")).toBe(0); + }); + + it("keeps the first safe port without closing the listener", async () => { + const fake = new FakeServer([49_152]); + + await expect(listenOnChromiumSafeEphemeralPort(fake, "bind failed")).resolves.toBe(49_152); + expect(fake.listenCalls).toEqual([0]); + expect(fake.closeCalls).toBe(0); + expect(fake.listenerCount("error")).toBe(0); + }); + + it("stops after bounded retries and leaves no listener open", async () => { + const fake = new FakeServer([3659, 3659, 3659]); + + await expect(listenOnChromiumSafeEphemeralPort(fake, "bind failed", 3)).rejects.toThrow( + "bind failed", + ); + expect(fake.listenCalls).toEqual([0, 0, 0]); + expect(fake.closeCalls).toBe(3); + expect(fake.address()).toBeNull(); + expect(fake.listenerCount("error")).toBe(0); + }); +}); + describe("serveStaticProjectHtml range support", () => { it("answers a Range request with 206 + the requested byte slice", async () => { // Chromium needs byte-range seekability or WAV `.duration` reports Infinity, diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index ea26451aff..4ad25db282 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -2,6 +2,7 @@ import { createServer, type ServerResponse } from "node:http"; import { createReadStream, existsSync, statSync } from "node:fs"; import { isAbsolute, relative, resolve } from "node:path"; import { getMimeType } from "@hyperframes/core/studio-api"; +import { isChromiumUnsafePort } from "./chromiumUnsafePorts.js"; export interface StaticProjectServer { url: string; @@ -9,6 +10,55 @@ export interface StaticProjectServer { close: () => Promise; } +interface EphemeralPortServer { + once(event: "error", listener: (error: Error) => void): unknown; + removeListener(event: "error", listener: (error: Error) => void): unknown; + listen(port: number, host: string, callback: () => void): unknown; + address(): string | { port: number } | null; + close(callback: (error?: Error) => void): unknown; +} + +const MAX_SAFE_PORT_BIND_ATTEMPTS = 10; + +function listenOnce(server: EphemeralPortServer, bindErrorMessage: string): Promise { + return new Promise((resolvePort, rejectPort) => { + const onError = (error: Error) => { + server.removeListener("error", onError); + rejectPort(error); + }; + server.once("error", onError); + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", onError); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + if (!port) rejectPort(new Error(bindErrorMessage)); + else resolvePort(port); + }); + }); +} + +function closeServer(server: EphemeralPortServer): Promise { + return new Promise((resolveClose, rejectClose) => { + server.close((error) => { + if (error) rejectClose(error); + else resolveClose(); + }); + }); +} + +export async function listenOnChromiumSafeEphemeralPort( + server: EphemeralPortServer, + bindErrorMessage: string, + maxAttempts = MAX_SAFE_PORT_BIND_ATTEMPTS, +): Promise { + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const port = await listenOnce(server, bindErrorMessage); + if (!isChromiumUnsafePort(port)) return port; + await closeServer(server); + } + throw new Error(`${bindErrorMessage}: no Chromium-safe port after ${maxAttempts} attempts`); +} + /** * Serve a file with HTTP Range support. Chromium needs byte-range seekability * to determine the duration of formats that carry it in a trailing/implicit @@ -99,18 +149,10 @@ export async function serveStaticProjectHtml( res.end(); }); - const port = await new Promise((resolvePort, rejectPort) => { - server.on("error", rejectPort); - // Bind loopback only (SECURITY F-001): a bare listen(0) binds 0.0.0.0/::, - // which an IDE's port auto-forward surfaces as a transient "preview". The - // snapshot browser is co-located (url below is already 127.0.0.1). - server.listen(0, "127.0.0.1", () => { - const addr = server.address(); - const resolvedPort = typeof addr === "object" && addr ? addr.port : 0; - if (!resolvedPort) rejectPort(new Error(bindErrorMessage)); - else resolvePort(resolvedPort); - }); - }); + // Bind loopback only (SECURITY F-001): a bare listen(0) binds 0.0.0.0/::, + // which an IDE's port auto-forward surfaces as a transient "preview". The + // snapshot browser is co-located (url below is already 127.0.0.1). + const port = await listenOnChromiumSafeEphemeralPort(server, bindErrorMessage); return { url: `http://127.0.0.1:${port}/`,