mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(cli): local servers never bind a port Chromium refuses to load from (#6620)
This commit is contained in:
@@ -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<void>((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<void> | undefined;
|
||||
|
||||
@@ -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<void>((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}`;
|
||||
|
||||
@@ -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<number>((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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<Server, "address" | "close" | "listen" | "once" | "off">;
|
||||
|
||||
function listenOnce(server: Bindable, host: string): Promise<number> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
await listenOnce(server, host);
|
||||
return settleOnBrowserSafePort(server, host);
|
||||
}
|
||||
@@ -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<FileServer
|
||||
// it must not listen on 0.0.0.0 where an IDE's port auto-forward surfaces
|
||||
// it as a transient, breakage-prone "preview".
|
||||
const server = serve({ fetch: app.fetch, port, hostname: "127.0.0.1" }, (info) => {
|
||||
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"]) => {
|
||||
|
||||
Reference in New Issue
Block a user