Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions packages/cli/src/utils/chromiumUnsafePorts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { CHROMIUM_UNSAFE_PORTS, isChromiumUnsafePort } from "./chromiumUnsafePorts.js";

describe("isChromiumUnsafePort", () => {
it("recognizes Chromium's restricted PasswordServer port", () => {
expect(CHROMIUM_UNSAFE_PORTS.has(3659)).toBe(true);
expect(isChromiumUnsafePort(3659)).toBe(true);
});

it("allows a representative ephemeral port", () => {
expect(isChromiumUnsafePort(49_152)).toBe(false);
});
});
15 changes: 15 additions & 0 deletions packages/cli/src/utils/chromiumUnsafePorts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Chromium's restricted-port list. Keep this synchronized with `kRestrictedPorts`:
* https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/base/port_util.cc
*/
export const CHROMIUM_UNSAFE_PORTS: ReadonlySet<number> = new Set([
0, 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,
]);

export function isChromiumUnsafePort(port: number): boolean {
return CHROMIUM_UNSAFE_PORTS.has(port);
}
68 changes: 67 additions & 1 deletion packages/cli/src/utils/staticProjectServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,42 @@
import { afterEach, describe, expect, it } from "vitest";
import { EventEmitter } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { serveStaticProjectHtml, type StaticProjectServer } from "./staticProjectServer.js";
import {
listenOnChromiumSafeEphemeralPort,
serveStaticProjectHtml,
type StaticProjectServer,
} from "./staticProjectServer.js";

class FakeServer extends EventEmitter {
private currentPort: number | undefined;
readonly listenCalls: number[] = [];
closeCalls = 0;

constructor(private readonly assignedPorts: readonly number[]) {
super();
}

listen(port: number, host: string, callback: () => void): this {
expect(host).toBe("127.0.0.1");
this.listenCalls.push(port);
this.currentPort = this.assignedPorts[this.listenCalls.length - 1];
queueMicrotask(callback);
return this;
}

address(): { port: number } | null {
return this.currentPort ? { port: this.currentPort } : null;
}

close(callback?: () => void): this {
this.closeCalls += 1;
this.currentPort = undefined;
queueMicrotask(() => callback?.());
return this;
}
}

let server: StaticProjectServer | undefined;
let dir: string | undefined;
Expand All @@ -21,6 +55,38 @@ async function serveWith(bytes: Buffer): Promise<{ url: string }> {
return { url: server.url };
}

describe("listenOnChromiumSafeEphemeralPort", () => {
it("closes and rebinds when the OS assigns a Chromium-unsafe port", async () => {
const fake = new FakeServer([3659, 49_152]);

await expect(listenOnChromiumSafeEphemeralPort(fake, "bind failed")).resolves.toBe(49_152);
expect(fake.listenCalls).toEqual([0, 0]);
expect(fake.closeCalls).toBe(1);
expect(fake.listenerCount("error")).toBe(0);
});

it("keeps the first safe port without closing the listener", async () => {
const fake = new FakeServer([49_152]);

await expect(listenOnChromiumSafeEphemeralPort(fake, "bind failed")).resolves.toBe(49_152);
expect(fake.listenCalls).toEqual([0]);
expect(fake.closeCalls).toBe(0);
expect(fake.listenerCount("error")).toBe(0);
});

it("stops after bounded retries and leaves no listener open", async () => {
const fake = new FakeServer([3659, 3659, 3659]);

await expect(listenOnChromiumSafeEphemeralPort(fake, "bind failed", 3)).rejects.toThrow(
"bind failed",
);
expect(fake.listenCalls).toEqual([0, 0, 0]);
expect(fake.closeCalls).toBe(3);
expect(fake.address()).toBeNull();
expect(fake.listenerCount("error")).toBe(0);
});
});

describe("serveStaticProjectHtml range support", () => {
it("answers a Range request with 206 + the requested byte slice", async () => {
// Chromium needs byte-range seekability or WAV `.duration` reports Infinity,
Expand Down
66 changes: 54 additions & 12 deletions packages/cli/src/utils/staticProjectServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,63 @@ import { createServer, type ServerResponse } from "node:http";
import { createReadStream, existsSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { getMimeType } from "@hyperframes/core/studio-api";
import { isChromiumUnsafePort } from "./chromiumUnsafePorts.js";

export interface StaticProjectServer {
url: string;
port: number;
close: () => Promise<void>;
}

interface EphemeralPortServer {
once(event: "error", listener: (error: Error) => void): unknown;
removeListener(event: "error", listener: (error: Error) => void): unknown;
listen(port: number, host: string, callback: () => void): unknown;
address(): string | { port: number } | null;
close(callback: (error?: Error) => void): unknown;
}

const MAX_SAFE_PORT_BIND_ATTEMPTS = 10;

function listenOnce(server: EphemeralPortServer, bindErrorMessage: string): Promise<number> {
return new Promise<number>((resolvePort, rejectPort) => {
const onError = (error: Error) => {
server.removeListener("error", onError);
rejectPort(error);
};
server.once("error", onError);
server.listen(0, "127.0.0.1", () => {
server.removeListener("error", onError);
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
if (!port) rejectPort(new Error(bindErrorMessage));
else resolvePort(port);
});
});
}

function closeServer(server: EphemeralPortServer): Promise<void> {
return new Promise<void>((resolveClose, rejectClose) => {
server.close((error) => {
if (error) rejectClose(error);
else resolveClose();
});
});
}

export async function listenOnChromiumSafeEphemeralPort(
server: EphemeralPortServer,
bindErrorMessage: string,
maxAttempts = MAX_SAFE_PORT_BIND_ATTEMPTS,
): Promise<number> {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const port = await listenOnce(server, bindErrorMessage);
if (!isChromiumUnsafePort(port)) return port;
await closeServer(server);
}
throw new Error(`${bindErrorMessage}: no Chromium-safe port after ${maxAttempts} attempts`);
}

/**
* Serve a file with HTTP Range support. Chromium needs byte-range seekability
* to determine the duration of formats that carry it in a trailing/implicit
Expand Down Expand Up @@ -99,18 +149,10 @@ 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 listenOnChromiumSafeEphemeralPort(server, bindErrorMessage);

return {
url: `http://127.0.0.1:${port}/`,
Expand Down
Loading