fix(web): only use a public address on a fixed listener port (#1862)

Addresses Copilot review round 2. A reverse proxy routes a public address to
one stable port, so neither MCP_SANDBOX_FULL_ADDRESS nor
MCP_APP_ORIGIN_FULL_ADDRESS is used when the listener's port is OS-assigned:
an explicit 0, a config-time collision resolved to 0, or the EADDRINUSE
fallback. Validation warnings also stop echoing the URL path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzuzRPJv7M8wGe3wyw5sSg
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-14 01:10:29 -04:00
commit 925461a043
3 changed files with 110 additions and 22 deletions

View file

@ -23,6 +23,18 @@ import {
} from "../../../../server/app-origin-controller.js";
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
/** A port that was free a moment ago, for tests that need a FIXED port. */
async function freePort(): Promise<number> {
const probe: Server = createServer();
await new Promise<void>((resolve) =>
probe.listen(0, "127.0.0.1", () => resolve()),
);
const addr = probe.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return port;
}
describe("resolveAppOriginPort", () => {
const saved = process.env.MCP_APP_ORIGIN_PORT;
afterEach(() => {
@ -180,12 +192,15 @@ describe("createAppOriginController", () => {
it("publishes under a public origin while still serving the bound listener (#1862)", async () => {
const publicOrigin = "https://apps.example.com";
// A fixed port: a public origin is only used where a proxy can route.
const port = await freePort();
controller = createAppOriginController({
port: 0,
port,
host: "127.0.0.1",
publicOrigin,
});
const first = await controller.start();
expect(first.port).toBe(port);
expect(first.url).toBe(publicOrigin);
expect(controller.getOrigin()).toBe(publicOrigin);
// The cached start reports the BOUND port; parsing one out of a public
@ -204,6 +219,27 @@ describe("createAppOriginController", () => {
await expect(res.text()).resolves.toBe("<p>proxied</p>");
});
it("does not use a public origin on an OS-assigned port (port 0)", async () => {
// Both an explicit MCP_APP_ORIGIN_PORT=0 and a config-time collision with
// CLIENT_PORT / MCP_SANDBOX_PORT (which buildWebServerConfig resolves to 0)
// land here: there is no stable port for a reverse proxy to route to.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
controller = createAppOriginController({
port: 0,
host: "127.0.0.1",
publicOrigin: "https://apps.example.com",
});
const { url, port } = await controller.start();
expect(url).toBe(`http://127.0.0.1:${port}`);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not using MCP_APP_ORIGIN_FULL_ADDRESS"),
);
} finally {
warnSpy.mockRestore();
}
});
it("does not adopt a public origin when the listener never bound", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
@ -423,7 +459,7 @@ describe("createAppOriginController", () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
try {
controller = createAppOriginController({
port: 0,
port: await freePort(),
host: "127.0.0.1",
publicOrigin: "http://127.0.0.1:6275",
embedderOrigins: appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [

View file

@ -83,6 +83,18 @@ describe("public addresses (#1862)", () => {
expect(warned()).not.toContain(secret);
});
it("never echoes a path, which can carry a secret too", () => {
expect(
resolveAppOriginPublicOrigin(
"https://apps.example.com/hunter2",
["https://inspector.example.com"],
undefined,
),
).toBeUndefined();
expect(warned()).toContain('"https://apps.example.com"');
expect(warned()).not.toContain("hunter2");
});
it("refuses an origin shared with the Inspector UI — the same-origin collapse", () => {
// The natural reverse-proxy layout: one hostname, /sandbox routed to 6275.
// The spec requires host != sandbox origin, so this must not be advertised.

View file

@ -14,6 +14,18 @@ import {
sandboxFrameAncestors,
} from "../../../../server/sandbox-controller.js";
/** A port that was free a moment ago, for tests that need a FIXED port. */
async function freePort(): Promise<number> {
const probe: Server = createServer();
await new Promise<void>((resolve) =>
probe.listen(0, "127.0.0.1", () => resolve()),
);
const addr = probe.address();
const port = typeof addr === "object" && addr !== null ? addr.port : 0;
await new Promise<void>((resolve) => probe.close(() => resolve()));
return port;
}
const PROXY_PAGE = join(
dirname(fileURLToPath(import.meta.url)),
"../../../../static/sandbox_proxy.html",
@ -276,8 +288,10 @@ describe("createSandboxController", () => {
it("advertises a public URL in place of the bind-derived one (#1862)", async () => {
const publicUrl = "https://sb.example.com/sandbox";
// A fixed port: a public URL is only advertised where a proxy can route.
const port = await freePort();
const controller = createSandboxController({
port: 0,
port,
host: "127.0.0.1",
publicUrl,
});
@ -286,7 +300,7 @@ describe("createSandboxController", () => {
expect(first.url).toBe(publicUrl);
expect(controller.getUrl()).toBe(publicUrl);
// The port is still the bound one — the public URL has none of its own.
expect(first.port).toBeGreaterThan(0);
expect(first.port).toBe(port);
expect(
(await fetch(`http://127.0.0.1:${first.port}/sandbox`)).status,
).toBe(200);
@ -298,6 +312,26 @@ describe("createSandboxController", () => {
}
});
it("does not advertise a public URL on an OS-assigned port (port 0)", async () => {
// MCP_SANDBOX_PORT=0 leaves no stable port for a reverse proxy to route to.
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const controller = createSandboxController({
port: 0,
host: "127.0.0.1",
publicUrl: "https://sb.example.com/sandbox",
});
try {
const { url, port } = await controller.start();
expect(url).toBe(`http://127.0.0.1:${port}/sandbox`);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("not advertising MCP_SANDBOX_FULL_ADDRESS"),
);
} finally {
warnSpy.mockRestore();
await controller.close();
}
});
it("does not advertise a public URL when the listener never bound", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const controller = createSandboxController({