mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(cli): bind studio preview server to loopback by default (#1210)
## Summary - Binds the Studio preview server (`packages/cli`) to `127.0.0.1` instead of `0.0.0.0` so it is only reachable from localhost. - Adds a `--host` flag for callers that genuinely need to expose the server on a wider interface (e.g. Docker, remote dev boxes). ## Security **F-001 HIGH** — Studio preview server was binding on all interfaces, making it reachable from any network the developer's machine was on (including shared Wi-Fi, corp LAN). Because the server serves the project filesystem under no auth, any peer on the same network could read arbitrary project files. Restricting to loopback closes this exposure for the default case. ## Test plan - [x] `hyperframes preview` starts — server reachable on `localhost:<port>`, not on LAN IP - [x] `hyperframes preview --host 0.0.0.0` still binds on all interfaces for Docker / remote-dev use cases - [x] Existing unit tests pass
This commit is contained in:
@@ -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<number> {
|
||||
const srv = createServer();
|
||||
@@ -18,26 +25,27 @@ async function allocFreePort(): Promise<number> {
|
||||
return port;
|
||||
}
|
||||
|
||||
async function closeAll(servers: Array<{ close(cb: () => void): void }>): Promise<void> {
|
||||
await Promise.all(
|
||||
servers.splice(0).map((s) => new Promise<void>((resolve) => s.close(() => resolve()))),
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
openServers.splice(0).map(
|
||||
(s) =>
|
||||
new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
await Promise.all(
|
||||
openHttpServers.splice(0).map(
|
||||
(s) =>
|
||||
new Promise<void>((resolve) => {
|
||||
s.close(() => resolve());
|
||||
}),
|
||||
),
|
||||
);
|
||||
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<string, unknown>): Promise<number> {
|
||||
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";
|
||||
|
||||
@@ -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<string | null> {
|
||||
async function getProcessOnPort(port: number): Promise<string | null> {
|
||||
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<FindPortResult> {
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user