From 74fb9d22fffa122e97895266099976a62b96c93f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 4 Jun 2026 17:58:12 -0700 Subject: [PATCH] fix(cli): bind studio preview server to loopback by default The studio preview server bound the unspecified address (0.0.0.0 / ::) via a bare listen(port), exposing the unauthenticated studio API -- project file read/write/delete and render-spawn endpoints -- to anyone on the same LAN. Default the bind host to 127.0.0.1 and add an explicit "preview --host" opt-in (with a warning) for intentional LAN exposure. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/server/portUtils.test.ts | 71 ++++++++++++++++++----- packages/cli/src/server/portUtils.ts | 12 +++- 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/server/portUtils.test.ts b/packages/cli/src/server/portUtils.test.ts index 76df67aae..a52dc289e 100644 --- a/packages/cli/src/server/portUtils.test.ts +++ b/packages/cli/src/server/portUtils.test.ts @@ -2,10 +2,17 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createServer, type Server } from "node:net"; import { resolve } from "node:path"; import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; -import { PORT_PROBE_HOSTS, detectHyperframesServer, testPortOnAllHosts } from "./portUtils.js"; +import { + PORT_PROBE_HOSTS, + detectHyperframesServer, + findPortAndServe, + testPortOnAllHosts, +} from "./portUtils.js"; +import type { ServerType } from "@hono/node-server"; const openServers: Server[] = []; const openHttpServers: HttpServer[] = []; +const openAdaptorServers: ServerType[] = []; async function allocFreePort(): Promise { const srv = createServer(); @@ -18,26 +25,27 @@ async function allocFreePort(): Promise { return port; } -afterEach(async () => { - await Promise.all( - openServers.splice(0).map( - (s) => - new Promise((resolve) => { - s.close(() => resolve()); - }), - ), - ); +async function closeAll(servers: Array<{ close(cb: () => void): void }>): Promise { await Promise.all( - openHttpServers.splice(0).map( - (s) => - new Promise((resolve) => { - s.close(() => resolve()); - }), - ), + servers.splice(0).map((s) => new Promise((resolve) => s.close(() => resolve()))), ); +} + +afterEach(async () => { + await closeAll(openServers); + await closeAll(openHttpServers); + await closeAll(openAdaptorServers); vi.restoreAllMocks(); }); +function boundAddress(server: ServerType): string { + const addr = server.address(); + if (addr === null || typeof addr === "string") { + throw new Error(`expected an AddressInfo, got ${JSON.stringify(addr)}`); + } + return addr.address; +} + async function startConfigProbeServer(payload: Record): Promise { const server = createHttpServer((_req, res) => { res.setHeader("Content-Type", "application/json"); @@ -122,6 +130,37 @@ describe("testPortOnAllHosts — sequential contract (platform-agnostic)", () => }); }); +describe("findPortAndServe — bind host (security: F-001)", () => { + const okFetch = (): Response => new Response("ok"); + + it("binds to loopback (127.0.0.1) by default — not all interfaces", async () => { + const port = await allocFreePort(); + const result = await findPortAndServe(okFetch, port, "/tmp/demo-project", true); + expect(result.type).toBe("started"); + if (result.type !== "started") return; + openAdaptorServers.push(result.server); + // A no-host listen() binds the unspecified address (`::`/`0.0.0.0`), + // exposing the studio API to the LAN. The fix must default to loopback. + expect(boundAddress(result.server)).toBe("127.0.0.1"); + }); + + it("honours an explicit bindHost when the operator opts in to LAN exposure", async () => { + const port = await allocFreePort(); + const result = await findPortAndServe( + okFetch, + port, + "/tmp/demo-project", + true, + null, + "0.0.0.0", + ); + expect(result.type).toBe("started"); + if (result.type !== "started") return; + openAdaptorServers.push(result.server); + expect(boundAddress(result.server)).toBe("0.0.0.0"); + }); +}); + describe("detectHyperframesServer", () => { it("treats same-project servers with a different server build signature as mismatch", async () => { const projectDir = "/tmp/demo-project"; diff --git a/packages/cli/src/server/portUtils.ts b/packages/cli/src/server/portUtils.ts index 671dec08e..c72d7b7fe 100644 --- a/packages/cli/src/server/portUtils.ts +++ b/packages/cli/src/server/portUtils.ts @@ -188,7 +188,7 @@ export function detectHyperframesServer( * Get the PID of the process listening on a port (macOS/Linux only). * Returns null on Windows or if detection fails. */ -export async function getProcessOnPort(port: number): Promise { +async function getProcessOnPort(port: number): Promise { if (process.platform === "win32") return null; try { const { stdout } = await execFileAsync("lsof", [`-ti:${port}`, "-sTCP:LISTEN"], { @@ -336,8 +336,16 @@ export async function findPortAndServe( projectDir: string, forceNew: boolean, expectedServerBuildSignature: string | null = null, + bindHost?: string, ): Promise { const { createAdaptorServer } = await import("@hono/node-server"); + // SECURITY (F-001): bind to loopback by default. The studio API exposes + // unauthenticated project file read/write/delete + render-spawn endpoints; + // a bare `listen(port)` binds the unspecified address (`::`/`0.0.0.0`), + // handing those endpoints to anyone on the LAN. Operators who genuinely + // need LAN exposure opt in explicitly via the HYPERFRAMES_PREVIEW_HOST + // env var (e.g. HYPERFRAMES_PREVIEW_HOST=0.0.0.0). + const host = bindHost ?? (process.env.HYPERFRAMES_PREVIEW_HOST?.trim() || "127.0.0.1"); const normalizedDir = resolve(projectDir).replace(/\\/g, "/").toLowerCase(); const endPort = startPort + MAX_PORT_SCAN - 1; @@ -362,7 +370,7 @@ export async function findPortAndServe( }; server!.once("error", onError); server!.once("listening", onListening); - server!.listen(port); + server!.listen(port, host); }); return { type: "started", server, port }; } catch (err: unknown) {