fix(cli): local servers never bind a port Chromium refuses to load from (#6620)

This commit is contained in:
Miguel Ángel
2026-10-28 05:16:42 +00:00
committed by GitHub
parent 310488b050
commit bf7ebe961e
7 changed files with 133 additions and 34 deletions
+2 -4
View File
@@ -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;
+2 -4
View File
@@ -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}`;
+9 -14
View File
@@ -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");
+6
View File
@@ -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);
}
+18 -12
View File
@@ -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"]) => {