diff --git a/packages/cli/src/capture/captureProxy.ts b/packages/cli/src/capture/captureProxy.ts index 84042b51c0..bbd9ea1290 100644 --- a/packages/cli/src/capture/captureProxy.ts +++ b/packages/cli/src/capture/captureProxy.ts @@ -8,6 +8,7 @@ import { openUpstreamTunnel, type UpstreamProxy, } from "./upstreamProxy.js"; +import { listenOnBrowserSafePort } from "@hyperframes/engine"; const CONNECTION_TIMEOUT_MS = 15_000; const HOP_HEADERS = new Set([ @@ -189,10 +190,7 @@ export async function startCaptureProxy(): Promise<{ url: string; close(): Promi server.headersTimeout = CONNECTION_TIMEOUT_MS; server.requestTimeout = 30_000; server.keepAliveTimeout = 1_000; - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolve); - }); + await listenOnBrowserSafePort(server, "127.0.0.1"); const address = server.address(); if (!address || typeof address === "string") throw new Error("Capture proxy did not bind"); let closing: Promise | undefined; diff --git a/packages/cli/src/capture/localPage.ts b/packages/cli/src/capture/localPage.ts index f500e2f184..22f3732f41 100644 --- a/packages/cli/src/capture/localPage.ts +++ b/packages/cli/src/capture/localPage.ts @@ -13,6 +13,7 @@ import type { Socket } from "node:net"; import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Readable } from "node:stream"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { listenOnBrowserSafePort } from "@hyperframes/engine"; export interface LocalCaptureTarget { /** Real path of the directory the page may read from. */ @@ -165,10 +166,7 @@ export async function startLocalPageServer(target: LocalCaptureTarget): Promise< sockets.add(socket); socket.once("close", () => sockets.delete(socket)); }); - await new Promise((resolveListen, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", resolveListen); - }); + await listenOnBrowserSafePort(server, "127.0.0.1"); const address = server.address(); if (!address || typeof address === "string") throw new Error("Local page server did not bind"); const origin = `http://127.0.0.1:${address.port}`; diff --git a/packages/cli/src/utils/staticProjectServer.ts b/packages/cli/src/utils/staticProjectServer.ts index 6029827aa9..500c3bf567 100644 --- a/packages/cli/src/utils/staticProjectServer.ts +++ b/packages/cli/src/utils/staticProjectServer.ts @@ -27,6 +27,7 @@ import { PROXY_VARIANT_CONFIG, type ProxyVariantRequest, } from "@hyperframes/studio-server/media-codec-map"; +import { listenOnBrowserSafePort } from "@hyperframes/engine"; export interface StaticProjectServer { url: string; @@ -343,21 +344,15 @@ 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 listenOnBrowserSafePort(server, "127.0.0.1") + .then((bound) => bound || Promise.reject(new Error(bindErrorMessage))) + .catch((error: unknown) => { + if (REFUSED_LISTEN.has((error as NodeJS.ErrnoException).code ?? "")) return 0; + throw error; }); - }).catch((error: unknown) => { - if (REFUSED_LISTEN.has((error as NodeJS.ErrnoException).code ?? "")) return 0; - throw error; - }); if (port === 0) { server.removeAllListeners("error"); diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 4a516291ef..e03db7ea94 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -337,6 +337,12 @@ export { BROWSER_GPU_NOT_SOFTWARE, } from "./utils/assertSwiftShader.js"; +export { + isBrowserSafePort, + listenOnBrowserSafePort, + settleOnBrowserSafePort, +} from "./utils/browserSafePort.js"; + export { readWebGlVendorInfoFromCanvas } from "./utils/readWebGlVendorInfoFromCanvas.js"; export { diff --git a/packages/engine/src/utils/browserSafePort.test.ts b/packages/engine/src/utils/browserSafePort.test.ts new file mode 100644 index 0000000000..4c42118c99 --- /dev/null +++ b/packages/engine/src/utils/browserSafePort.test.ts @@ -0,0 +1,48 @@ +import { EventEmitter } from "node:events"; +import { createServer } from "node:net"; +import { describe, expect, it } from "vitest"; +import { isBrowserSafePort, listenOnBrowserSafePort } from "./browserSafePort.js"; + +class OfferedPorts extends EventEmitter { + bound = 0; + closed = 0; + constructor(private offers: number[]) { + super(); + } + listen(_port: number, _host: string, done: () => void) { + this.bound = this.offers.shift() as number; + queueMicrotask(done); + return this; + } + address() { + return { port: this.bound }; + } + close(done: () => void) { + this.closed++; + queueMicrotask(done); + return this; + } +} + +describe("listenOnBrowserSafePort", () => { + it("rebinds past a port Chromium blocks", async () => { + const server = new OfferedPorts([4045, 6000, 40000]); + const port = await listenOnBrowserSafePort(server as never, "127.0.0.1"); + expect(port).toBe(40000); + expect(server.closed).toBe(2); + }); + + it("gives up when the OS only offers blocked ports", async () => { + const server = new OfferedPorts(Array(40).fill(4045)); + await expect(listenOnBrowserSafePort(server as never, "127.0.0.1")).rejects.toThrow( + /restricted/, + ); + }); + + it("binds a real server on a safe port", async () => { + const server = createServer(); + const port = await listenOnBrowserSafePort(server, "127.0.0.1"); + expect(isBrowserSafePort(port)).toBe(true); + await new Promise((resolve) => server.close(resolve)); + }); +}); diff --git a/packages/engine/src/utils/browserSafePort.ts b/packages/engine/src/utils/browserSafePort.ts new file mode 100644 index 0000000000..5f98327ffa --- /dev/null +++ b/packages/engine/src/utils/browserSafePort.ts @@ -0,0 +1,48 @@ +import type { AddressInfo, Server } from "node:net"; + +// Chromium refuses to load from these ports (net::ERR_UNSAFE_PORT); net/base/port_util.cc kRestrictedPorts. +const RESTRICTED_PORTS = new Set([ + 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, +]); + +const MAX_ATTEMPTS = 32; + +export const isBrowserSafePort = (port: number): boolean => !RESTRICTED_PORTS.has(port); + +type Bindable = Pick; + +function listenOnce(server: Bindable, host: string): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, host, () => { + server.off("error", reject); + resolve((server.address() as AddressInfo).port); + }); + }); +} + +function closeServer(server: Bindable): Promise { + return new Promise((resolve) => server.close(() => resolve())); +} + +/** Rebinds an already-listening server until the OS hands it a port a browser will load from. */ +export async function settleOnBrowserSafePort(server: Bindable, host: string): Promise { + let port = (server.address() as AddressInfo).port; + for (let attempt = 1; !isBrowserSafePort(port); attempt++) { + if (attempt >= MAX_ATTEMPTS) + throw new Error(`The OS kept offering browser-restricted ports (last: ${port}).`); + await closeServer(server); + port = await listenOnce(server, host); + } + return port; +} + +/** `server.listen(0, host)` that never lands on a port Chromium blocks; resolves to the bound port. */ +export async function listenOnBrowserSafePort(server: Bindable, host: string): Promise { + await listenOnce(server, host); + return settleOnBrowserSafePort(server, host); +} diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 85ef4705db..e264d55665 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -10,6 +10,7 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; +import { settleOnBrowserSafePort } from "@hyperframes/engine"; import type { IncomingMessage } from "node:http"; import { existsSync, @@ -929,18 +930,23 @@ export function createFileServer(options: FileServerOptions): Promise { - resolve({ - url: `http://localhost:${info.port}`, - port: info.port, - addPreHeadScript: (script: string) => { - preHeadScripts.push(script); - }, - close: () => { - for (const socket of connections) socket.destroy(); - connections.clear(); - server.close(); - }, - }); + // An OS-picked port (0) must be one Chromium will load from; a caller-chosen one is theirs. + const bound = + port === 0 ? settleOnBrowserSafePort(server, "127.0.0.1") : Promise.resolve(info.port); + bound.then((boundPort) => { + resolve({ + url: `http://localhost:${boundPort}`, + port: boundPort, + addPreHeadScript: (script: string) => { + preHeadScripts.push(script); + }, + close: () => { + for (const socket of connections) socket.destroy(); + connections.clear(); + server.close(); + }, + }); + }, reject); }); server.on("connection", (socket: IncomingMessage["socket"]) => {