Merge pull request #2370 from modelcontextprotocol/v2/feat/1862-sandbox-public-url
feat(web): configurable public addresses for the MCP Apps listeners
This commit is contained in:
commit
fd6e60148b
4 changed files with 998 additions and 19 deletions
|
|
@ -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(() => {
|
||||
|
|
@ -178,6 +190,73 @@ describe("createAppOriginController", () => {
|
|||
await expect(res.text()).resolves.toBe("<!doctype html><p>app</p>");
|
||||
});
|
||||
|
||||
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,
|
||||
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
|
||||
// origin with no explicit port would yield NaN.
|
||||
expect(await controller.start()).toEqual(first);
|
||||
|
||||
const published = controller.publish({ html: "<p>proxied</p>" })!;
|
||||
expect(published.url.startsWith(`${publicOrigin}/app-document/`)).toBe(
|
||||
true,
|
||||
);
|
||||
// The reverse proxy's job is routing the public path to this listener;
|
||||
// simulate it by fetching the same path on the bound port.
|
||||
const path = new URL(published.url).pathname;
|
||||
const res = await fetch(`http://127.0.0.1:${first.port}${path}`);
|
||||
expect(res.status).toBe(200);
|
||||
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 {
|
||||
controller = createAppOriginController({
|
||||
port: 0,
|
||||
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
|
||||
publicOrigin: "https://apps.example.com",
|
||||
});
|
||||
expect(await controller.start()).toEqual({ port: 0, url: "" });
|
||||
expect(controller.getOrigin()).toBeNull();
|
||||
// So the renderer takes its srcdoc fallback instead of a dead URL.
|
||||
expect(controller.publish({ html: "<p>x</p>" })).toBeNull();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("serves only frame-ancestors when the document carries no csp", async () => {
|
||||
controller = createAppOriginController({ port: 0, host: "127.0.0.1" });
|
||||
await controller.start();
|
||||
|
|
@ -350,6 +429,54 @@ describe("createAppOriginController", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("does not use a public origin after falling back off its pinned port (#1862)", async () => {
|
||||
const { port, release } = await claimPort();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
controller = createAppOriginController({
|
||||
port,
|
||||
host: "127.0.0.1",
|
||||
publicOrigin: "https://apps.example.com",
|
||||
});
|
||||
const result = await controller.start();
|
||||
expect(result.port).not.toBe(port);
|
||||
expect(result.url).toBe(`http://127.0.0.1:${result.port}`);
|
||||
expect(
|
||||
controller.publish({ html: "<p>x</p>" })!.url.startsWith(result.url),
|
||||
).toBe(true);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not using MCP_APP_ORIGIN_FULL_ADDRESS"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a public origin equal to a trusted ancestor, including a bind-derived sandbox (#1862)", async () => {
|
||||
// Config time cannot see the bind-derived sandbox origin; the callers pass
|
||||
// it in embedderOrigins, so the controller is where the collision is caught.
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
controller = createAppOriginController({
|
||||
port: await freePort(),
|
||||
host: "127.0.0.1",
|
||||
publicOrigin: "http://127.0.0.1:6275",
|
||||
embedderOrigins: appDocumentEmbedders("http://127.0.0.1:6275/sandbox", [
|
||||
"http://localhost:6274",
|
||||
]),
|
||||
});
|
||||
const result = await controller.start();
|
||||
expect(result.url).toBe(`http://127.0.0.1:${result.port}`);
|
||||
expect(controller.getOrigin()).not.toBe("http://127.0.0.1:6275");
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("must differ from both"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades to publish()===null when listen fails for a non-EADDRINUSE reason", async () => {
|
||||
// The backends await start() during boot; if it ever stopped resolving the
|
||||
// whole web server would hang. Instead it resolves empty and every publish
|
||||
|
|
|
|||
196
clients/web/src/test/integration/server/public-address.test.ts
Normal file
196
clients/web/src/test/integration/server/public-address.test.ts
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
/**
|
||||
* Public addresses for the MCP Apps listeners (#1862): `MCP_SANDBOX_FULL_ADDRESS`
|
||||
* and `MCP_APP_ORIGIN_FULL_ADDRESS`, as parsed by `server/public-address.ts`.
|
||||
*
|
||||
* Pinned here: what each accepts and how it is normalized (`/sandbox` appended
|
||||
* to a bare origin, the app origin kept origin-only), every refusal reason, the
|
||||
* same-origin refusals that keep the sandbox and a dedicated app origin off the
|
||||
* Inspector's own origin, the mixed-content warning, and that a refused value
|
||||
* never leaks a secret (credentials, query, fragment, path) into the log. The
|
||||
* listener-side rules — fixed port only, no trusted-ancestor collision — are in
|
||||
* the sandbox and app-origin controller suites, where the bound port is known.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
resolveAppOriginPublicOrigin,
|
||||
resolveSandboxPublicUrl,
|
||||
} from "../../../../server/public-address.js";
|
||||
|
||||
const UI = ["http://localhost:6274", "http://127.0.0.1:6274"];
|
||||
|
||||
describe("public addresses (#1862)", () => {
|
||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
/** The single warning line, or undefined when nothing was warned. */
|
||||
function warned(): string | undefined {
|
||||
return (
|
||||
warnSpy.mock.calls.map((c: unknown[]) => String(c[0])).join("\n") ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveSandboxPublicUrl", () => {
|
||||
it.each([[undefined], [""], [" "]])(
|
||||
"is unset, silently, for %j",
|
||||
(raw) => {
|
||||
expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined();
|
||||
expect(warned()).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("advertises a full URL as given", () => {
|
||||
expect(
|
||||
resolveSandboxPublicUrl(
|
||||
"https://inspector-sandbox.example.com/sandbox",
|
||||
["https://inspector.example.com"],
|
||||
),
|
||||
).toBe("https://inspector-sandbox.example.com/sandbox");
|
||||
expect(warned()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends /sandbox to a bare origin, the only path the listener serves", () => {
|
||||
expect(
|
||||
resolveSandboxPublicUrl(" https://sb.example.com ", [
|
||||
"https://inspector.example.com",
|
||||
]),
|
||||
).toBe("https://sb.example.com/sandbox");
|
||||
});
|
||||
|
||||
it("keeps a non-root path, which a reverse proxy may rewrite", () => {
|
||||
expect(
|
||||
resolveSandboxPublicUrl("https://sb.example.com/mcp/sandbox", UI),
|
||||
).toBe("https://sb.example.com/mcp/sandbox");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["not a url", "not an absolute URL"],
|
||||
["ftp://sb.example.com/sandbox", "http:// or https://"],
|
||||
["https://user:pw@sb.example.com/sandbox", "credentials"],
|
||||
["https://*.example.com/sandbox", "wildcard"],
|
||||
["http://[::1]:6275/sandbox", "IPv6"],
|
||||
["https://sb.example.com/sandbox?x=1", "query string or fragment"],
|
||||
["https://sb.example.com/sandbox#frag", "query string or fragment"],
|
||||
])("refuses %s", (raw, reason) => {
|
||||
expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined();
|
||||
expect(warned()).toContain("MCP_SANDBOX_FULL_ADDRESS");
|
||||
expect(warned()).toContain(reason);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["https://user:hunter2@sb.example.com/sandbox", "hunter2"],
|
||||
["https://sb.example.com/sandbox?token=hunter2", "hunter2"],
|
||||
["https://sb.example.com/sandbox#hunter2", "hunter2"],
|
||||
["not a url hunter2", "hunter2"],
|
||||
])("never echoes a secret from a refused value (%s)", (raw, secret) => {
|
||||
expect(resolveSandboxPublicUrl(raw, UI)).toBeUndefined();
|
||||
expect(warned()).toContain("MCP_SANDBOX_FULL_ADDRESS");
|
||||
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.
|
||||
expect(
|
||||
resolveSandboxPublicUrl("https://inspector.example.com/sandbox", [
|
||||
"https://inspector.example.com",
|
||||
]),
|
||||
).toBeUndefined();
|
||||
expect(warned()).toContain("different origin from its host");
|
||||
});
|
||||
|
||||
it("advertises, but warns about, plain http under an https UI", () => {
|
||||
expect(
|
||||
resolveSandboxPublicUrl("http://sb.example.com/sandbox", [
|
||||
"https://inspector.example.com",
|
||||
]),
|
||||
).toBe("http://sb.example.com/sandbox");
|
||||
expect(warned()).toContain("mixed content");
|
||||
});
|
||||
|
||||
it("does not warn about http when every UI origin is http", () => {
|
||||
resolveSandboxPublicUrl("http://sb.example.com/sandbox", UI);
|
||||
expect(warned()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAppOriginPublicOrigin", () => {
|
||||
const SANDBOX = "https://sb.example.com/sandbox";
|
||||
const HOST = ["https://inspector.example.com"];
|
||||
|
||||
it("is unset, silently, for a blank value", () => {
|
||||
expect(resolveAppOriginPublicOrigin(" ", HOST, SANDBOX)).toBeUndefined();
|
||||
expect(warned()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns the canonical origin", () => {
|
||||
expect(
|
||||
resolveAppOriginPublicOrigin(
|
||||
"https://Apps.Example.com/",
|
||||
HOST,
|
||||
SANDBOX,
|
||||
),
|
||||
).toBe("https://apps.example.com");
|
||||
expect(warned()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses a path, since documents are served at the origin root", () => {
|
||||
expect(
|
||||
resolveAppOriginPublicOrigin(
|
||||
"https://apps.example.com/x",
|
||||
HOST,
|
||||
SANDBOX,
|
||||
),
|
||||
).toBeUndefined();
|
||||
expect(warned()).toContain("origin only");
|
||||
});
|
||||
|
||||
it("refuses a malformed value", () => {
|
||||
expect(
|
||||
resolveAppOriginPublicOrigin("javascript:alert(1)", HOST, SANDBOX),
|
||||
).toBeUndefined();
|
||||
expect(warned()).toContain("MCP_APP_ORIGIN_FULL_ADDRESS");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the Inspector UI", "https://inspector.example.com"],
|
||||
["the sandbox proxy", "https://sb.example.com"],
|
||||
])("refuses an origin shared with %s", (_label, raw) => {
|
||||
expect(resolveAppOriginPublicOrigin(raw, HOST, SANDBOX)).toBeUndefined();
|
||||
expect(warned()).toContain("must differ from both");
|
||||
});
|
||||
|
||||
it("checks only the UI origins when the sandbox URL is bind-derived", () => {
|
||||
expect(
|
||||
resolveAppOriginPublicOrigin("https://sb.example.com", HOST, undefined),
|
||||
).toBe("https://sb.example.com");
|
||||
});
|
||||
|
||||
it("warns about plain http under an https UI", () => {
|
||||
expect(
|
||||
resolveAppOriginPublicOrigin("http://apps.example.com", HOST, SANDBOX),
|
||||
).toBe("http://apps.example.com");
|
||||
expect(warned()).toContain("mixed content");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,84 @@
|
|||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
createSandboxController,
|
||||
DEFAULT_SANDBOX_PORT,
|
||||
EMBEDDER_ORIGINS_PLACEHOLDER,
|
||||
embedderOriginsLiteral,
|
||||
renderSandboxProxyHtml,
|
||||
resolveSandboxPort,
|
||||
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",
|
||||
);
|
||||
|
||||
describe("embedder allow-list injection (#1862)", () => {
|
||||
it("emits the same filtered list frame-ancestors admits", () => {
|
||||
const origins = [
|
||||
"https://inspector.example.com",
|
||||
"http://[::1]:6274", // dropped by both
|
||||
"http://a:1; sandbox", // dropped by both
|
||||
];
|
||||
expect(embedderOriginsLiteral(origins)).toBe(
|
||||
'["https://inspector.example.com"]',
|
||||
);
|
||||
expect(sandboxFrameAncestors(origins)).toBe(
|
||||
"frame-ancestors https://inspector.example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([[undefined], [[]], [["http://[::1]:6274"]]])(
|
||||
"is null — the page's loopback fallback — when nothing valid remains (%j)",
|
||||
(origins) => {
|
||||
expect(embedderOriginsLiteral(origins as string[] | undefined)).toBe(
|
||||
"null",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("escapes < so a value cannot close the <script> element", () => {
|
||||
const literal = embedderOriginsLiteral(["http://x</script><b>:1"]);
|
||||
expect(literal).not.toContain("<");
|
||||
// Still the same value once parsed back as JavaScript/JSON.
|
||||
expect(JSON.parse(literal)).toEqual(["http://x</script><b>:1"]);
|
||||
});
|
||||
|
||||
it("does not interpret replacement patterns in the literal", () => {
|
||||
expect(
|
||||
renderSandboxProxyHtml(`a ${EMBEDDER_ORIGINS_PLACEHOLDER} b`, [
|
||||
"http://$&.example:1",
|
||||
]),
|
||||
).toBe('a ["http://$&.example:1"] b');
|
||||
});
|
||||
|
||||
it("finds exactly one placeholder in the shipped proxy page", () => {
|
||||
// If the page's marker drifts from the constant, the substitution silently
|
||||
// no-ops and every non-loopback deployment is refused again.
|
||||
const page = readFileSync(PROXY_PAGE, "utf-8");
|
||||
expect(page.split(EMBEDDER_ORIGINS_PLACEHOLDER)).toHaveLength(2);
|
||||
expect(page).not.toContain("ALLOWED_REFERRER_PATTERN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sandboxFrameAncestors", () => {
|
||||
it("derives the directive from the provided allow-list", () => {
|
||||
expect(
|
||||
|
|
@ -198,6 +269,85 @@ describe("createSandboxController", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("serves the proxy page with the allow-list injected", async () => {
|
||||
const controller = createSandboxController({
|
||||
port: 0,
|
||||
allowedOrigins: ["https://inspector.example.com"],
|
||||
});
|
||||
try {
|
||||
const { url } = await controller.start();
|
||||
const body = await (await fetch(url)).text();
|
||||
expect(body).toContain(
|
||||
'const ALLOWED_EMBEDDER_ORIGINS = ["https://inspector.example.com"];',
|
||||
);
|
||||
expect(body).not.toContain(EMBEDDER_ORIGINS_PLACEHOLDER);
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
host: "127.0.0.1",
|
||||
publicUrl,
|
||||
});
|
||||
try {
|
||||
const first = await controller.start();
|
||||
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).toBe(port);
|
||||
expect(
|
||||
(await fetch(`http://127.0.0.1:${first.port}/sandbox`)).status,
|
||||
).toBe(200);
|
||||
// The cached second start must report the bound port, not parse one out
|
||||
// of the public URL (which would be NaN here).
|
||||
expect(await controller.start()).toEqual(first);
|
||||
} finally {
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
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({
|
||||
port: 0,
|
||||
host: "203.0.113.1", // TEST-NET-3: not assigned to any local interface
|
||||
publicUrl: "https://sb.example.com/sandbox",
|
||||
});
|
||||
try {
|
||||
expect(await controller.start()).toEqual({ port: 0, url: "" });
|
||||
expect(controller.getUrl()).toBeNull();
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
await controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("advertises localhost in the sandbox URL for a wildcard bind", async () => {
|
||||
// 0.0.0.0 isn't reachable from the browser, but a wildcard bind serves
|
||||
// loopback — so the URL handed to the client (and printed in the banner)
|
||||
|
|
@ -379,6 +529,31 @@ describe("createSandboxController", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("does not advertise a public URL after falling back off its pinned port (#1862)", async () => {
|
||||
// The reverse proxy routes the public URL to the pinned port, which another
|
||||
// process holds — so the public URL would reach the wrong listener.
|
||||
const { port, release } = await claimPort();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const controller = createSandboxController({
|
||||
port,
|
||||
host: "127.0.0.1",
|
||||
publicUrl: "https://sb.example.com/sandbox",
|
||||
});
|
||||
try {
|
||||
const result = await controller.start();
|
||||
expect(result.port).not.toBe(port);
|
||||
expect(result.url).toBe(`http://127.0.0.1:${result.port}/sandbox`);
|
||||
expect(controller.getUrl()).toBe(result.url);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining("not advertising MCP_SANDBOX_FULL_ADDRESS"),
|
||||
);
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
await controller.close();
|
||||
await release();
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves with empty values when even the dynamic retry fails", async () => {
|
||||
// The Vite plugin awaits start() in configureServer; if start() ever stops
|
||||
// resolving, the entire dev backend hangs. This pins the resolve-on-error
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ const MUTATED_ENV_KEYS = [
|
|||
"ALLOWED_ORIGINS",
|
||||
"MCP_SANDBOX_PORT",
|
||||
"MCP_APP_ORIGIN_PORT",
|
||||
"MCP_SANDBOX_FULL_ADDRESS",
|
||||
"MCP_APP_ORIGIN_FULL_ADDRESS",
|
||||
"SERVER_PORT",
|
||||
"MCP_LOG_FILE",
|
||||
"MCP_AUTO_OPEN_ENABLED",
|
||||
|
|
@ -342,6 +344,40 @@ describe("buildWebServerConfigFromEnv", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("leaves the public addresses unset by default (#1862)", () => {
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.sandboxPublicUrl).toBeUndefined();
|
||||
expect(cfg.appOriginPublicOrigin).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves both public addresses against the resolved allow-list", () => {
|
||||
process.env.ALLOWED_ORIGINS = "https://inspector.example.com";
|
||||
process.env.MCP_SANDBOX_FULL_ADDRESS = "https://sb.example.com";
|
||||
process.env.MCP_APP_ORIGIN_FULL_ADDRESS = "https://apps.example.com";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.sandboxPublicUrl).toBe("https://sb.example.com/sandbox");
|
||||
expect(cfg.appOriginPublicOrigin).toBe("https://apps.example.com");
|
||||
});
|
||||
|
||||
it("refuses public addresses that collapse onto the Inspector or sandbox origin", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
// The DEFAULT allow-list is what the sandbox is checked against when
|
||||
// ALLOWED_ORIGINS is unset — so the loopback UI origin is refused too.
|
||||
process.env.MCP_SANDBOX_FULL_ADDRESS = "http://localhost:6274/sandbox";
|
||||
expect(buildWebServerConfigFromEnv().sandboxPublicUrl).toBeUndefined();
|
||||
|
||||
// The app origin is checked against the overridden sandbox's origin.
|
||||
process.env.MCP_SANDBOX_FULL_ADDRESS = "https://sb.example.com/sandbox";
|
||||
process.env.MCP_APP_ORIGIN_FULL_ADDRESS = "https://sb.example.com";
|
||||
const cfg = buildWebServerConfigFromEnv();
|
||||
expect(cfg.sandboxPublicUrl).toBe("https://sb.example.com/sandbox");
|
||||
expect(cfg.appOriginPublicOrigin).toBeUndefined();
|
||||
} finally {
|
||||
warnSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an explicit dynamic app-origin port alone", () => {
|
||||
// 0 means "OS-assigned" for both, so two zeros are not a collision.
|
||||
process.env.CLIENT_PORT = "6274";
|
||||
|
|
|
|||
Loading…
Reference in a new issue