feat(web): configurable public addresses for the MCP Apps listeners (#1862)

Add MCP_SANDBOX_FULL_ADDRESS and MCP_APP_ORIGIN_FULL_ADDRESS so a reverse
proxy or ingress deployment can advertise the sandbox proxy URL and the
_meta.ui.domain app origin the browser actually reaches, instead of the
bind-derived http://localhost:<port> ones. The process still listens on
MCP_SANDBOX_PORT / MCP_APP_ORIGIN_PORT.

Both values are validated in public-address.ts and refused (warned, bind-
derived address kept) when malformed or when their origin collapses onto
the Inspector UI's (or, for the app origin, the sandbox's) — the MCP Apps
spec requires host != sandbox origin, and the single-hostname proxy layout
is exactly the one that breaks it. Plain http under an https UI is warned.

The sandbox proxy page's referrer check was a hardcoded
http://localhost|127.0.0.1 pattern no https embedder could match. It now
compares the referrer's parsed origin against an exact list injected by the
controller, derived from the same filtered allow-list as its frame-ancestors
header, with the old loopback check kept only as the no-list fallback.

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 00:10:34 -04:00
commit 12544d251c
4 changed files with 737 additions and 16 deletions

View file

@ -178,6 +178,49 @@ 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";
controller = createAppOriginController({
port: 0,
host: "127.0.0.1",
publicOrigin,
});
const first = await controller.start();
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 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();

View file

@ -0,0 +1,160 @@
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("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");
});
});
});

View file

@ -1,13 +1,72 @@
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";
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 +257,63 @@ 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";
const controller = createSandboxController({
port: 0,
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).toBeGreaterThan(0);
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 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)

View file

@ -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";