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:
parent
3e728a3b8c
commit
12544d251c
14 changed files with 737 additions and 16 deletions
|
|
@ -417,7 +417,22 @@ The guard blocks only the **wildcard** all-interfaces addresses. Binding a **spe
|
|||
|
||||
The bind-host guard and the `ALLOWED_ORIGINS` allow-list apply to both the prod server and `--dev`. Note that in **`--dev`** the Vite dev server _additionally_ enforces its own `server.allowedHosts` Host-header check, whose default accepts loopback and IP-literal hosts. The host you **bind** is auto-allowed (Vite adds the resolved `server.host` — which this config sets from `HOST` — to the allow-list), so `HOST=<hostname>` works out of the box under `--dev` too. What needs an explicit `server.allowedHosts` entry is reaching the dev server at a **different** name than the one bound — e.g. a wildcard bind reached by hostname, or a reverse-proxy domain. For those, prefer the prod server (`mcp-inspector --web`) or add the host to `server.allowedHosts`.
|
||||
|
||||
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is always `http://` — so **behind TLS** (an `https://` app page) the browser blocks the `http://…/sandbox` iframe as mixed content and MCP Apps can't render; the Apps tab needs a plain-`http` app origin today.
|
||||
**MCP Apps caveats.** The MCP Apps sandbox runs on a **separate** port — `MCP_SANDBOX_PORT`, defaulting to a fixed **`6275`** (#2008; it was OS-assigned before, which meant it changed every run and so could never be named in a `forwardPorts` / `-p` / tunnel config written ahead of time). For the Apps tab to work off loopback, that sandbox port must be independently reachable from the browser — expose/forward `6275` alongside `6274`, or set `MCP_SANDBOX_PORT` to pick another. If the port is already taken the sandbox falls back to an OS-assigned one and warns, so a second Inspector still gets a working Apps tab locally — but the forwarded port is then wrong, which is what the warning tells you. (Under a `0.0.0.0` wildcard bind the sandbox URL is advertised as `localhost`, which is reachable — a wildcard bind serves loopback — so only the port needs handling.) Also note the sandbox iframe is gated by a `frame-ancestors` CSP, and **a bracketed IPv6 literal is not a valid CSP host-source** — so MCP Apps requires browsing the app at a name or IPv4 (`localhost`, `127.0.0.1`, a hostname, a LAN IPv4), **not** a bare `http://[::1]:…` address. Finally, the sandbox URL is built from the bind, so it is always `http://<bind host>:<port>/sandbox` — which is wrong **behind a reverse proxy or TLS**, where the browser reaches the listener at a public hostname (and an `https://` app page blocks an `http://` iframe as mixed content).
|
||||
|
||||
**Behind a reverse proxy: `MCP_SANDBOX_FULL_ADDRESS` and `MCP_APP_ORIGIN_FULL_ADDRESS`** (#1862). These set the address the browser is _told_ to use; the process still listens on `MCP_SANDBOX_PORT` / `MCP_APP_ORIGIN_PORT`, and routing the public address to that listener is the proxy's job.
|
||||
|
||||
```sh
|
||||
ALLOWED_ORIGINS=https://inspector.example.com \
|
||||
MCP_SANDBOX_FULL_ADDRESS=https://inspector-sandbox.example.com/sandbox \
|
||||
MCP_APP_ORIGIN_FULL_ADDRESS=https://inspector-apps.example.com \
|
||||
mcp-inspector --web
|
||||
```
|
||||
|
||||
- **Each needs its own origin** — its own hostname, or its own port. The MCP Apps spec requires the sandbox origin to differ from the host's, so the natural single-hostname layout (`https://inspector.example.com/sandbox` routed to `6275`) is **refused** at boot with a warning naming the collision, as is an app origin equal to the Inspector's or the sandbox's. A refused or malformed value (no `http(s)` scheme, credentials, a query, a wildcard, a bracketed IPv6 literal, or a path on the app origin) is warned and ignored, and the bind-derived address is advertised instead.
|
||||
- `MCP_SANDBOX_FULL_ADDRESS` may be a bare origin; `/sandbox` is appended. `MCP_APP_ORIGIN_FULL_ADDRESS` must be an origin only — documents are served at `<origin>/app-document/<id>`.
|
||||
- **`ALLOWED_ORIGINS` must name the public Inspector origin.** It already had to for connects; it is now also the sandbox proxy's embedder allow-list — the proxy checks the embedding page's origin against exactly the list its `frame-ancestors` header admits, rather than a hardcoded `http://localhost` pattern that no `https://` page could match. It **replaces** the defaults, so add the loopback origins too if you still browse locally.
|
||||
- Set **both** if a server you inspect declares `_meta.ui.domain`: with only the sandbox overridden, that app is still served from `http://<bind host>:6278`, which an `https` page blocks and a remote browser cannot reach — and that frame stays blank with no fallback. A plain-`http` address under an `https` `ALLOWED_ORIGINS` entry is advertised but warned about for the same reason.
|
||||
- The web client itself still needs a secure context off loopback (`crypto.randomUUID` is unavailable over plain HTTP on a non-`localhost` host), so serve the Inspector over HTTPS when it is not on `localhost`.
|
||||
|
||||
In every case, exposing the Inspector beyond loopback also means anyone who can reach it can drive its backend — keep authentication on (do **not** set `DANGEROUSLY_OMIT_AUTH`) and prefer a specific bind address over the wildcard.
|
||||
|
||||
|
|
|
|||
|
|
@ -162,6 +162,14 @@ export interface AppOriginControllerOptions {
|
|||
* loopback, exactly as the sandbox proxy's own `frame-ancestors` does.
|
||||
*/
|
||||
embedderOrigins?: string[];
|
||||
/**
|
||||
* The origin to serve published documents under instead of the bind-derived
|
||||
* one — the operator's `MCP_APP_ORIGIN_FULL_ADDRESS`, already validated by
|
||||
* `public-address.ts` (#1862). Only adopted once the listener is up, so a
|
||||
* failed bind still makes {@link AppOriginController.publish} return null
|
||||
* and the renderer keeps its srcdoc fallback.
|
||||
*/
|
||||
publicOrigin?: string;
|
||||
}
|
||||
|
||||
/** A document handed to {@link AppOriginController.publish}. */
|
||||
|
|
@ -237,9 +245,17 @@ export function createAppOriginController(
|
|||
// Same defaulting rationale as the sandbox controller: never the *name*
|
||||
// `localhost`, which resolves to a single address family and would put this
|
||||
// listener on a different family than the web server (#1951).
|
||||
const { port, host = DEFAULT_BIND_HOST, embedderOrigins } = options;
|
||||
const {
|
||||
port,
|
||||
host = DEFAULT_BIND_HOST,
|
||||
embedderOrigins,
|
||||
publicOrigin,
|
||||
} = options;
|
||||
let server: Server | null = null;
|
||||
let origin: string | null = null;
|
||||
// Tracked separately from `origin`: a public origin carries the public port
|
||||
// (or none), not the one this listener bound.
|
||||
let boundPort = 0;
|
||||
|
||||
// Insertion-ordered, so the first key is the oldest entry.
|
||||
const documents = new Map<string, StoredDocument>();
|
||||
|
|
@ -268,7 +284,7 @@ export function createAppOriginController(
|
|||
return {
|
||||
async start(): Promise<{ port: number; url: string }> {
|
||||
if (server && origin) {
|
||||
return { port: parseInt(new URL(origin).port, 10), url: origin };
|
||||
return { port: boundPort, url: origin };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
|
|
@ -364,7 +380,10 @@ export function createAppOriginController(
|
|||
const urlHost = isAllInterfacesHost(canonicalHost)
|
||||
? "localhost"
|
||||
: canonicalHost;
|
||||
origin = `http://${urlHost}:${actualPort}`;
|
||||
// An operator-supplied public origin (#1862) replaces the derived one
|
||||
// only now that the listener it routes to is actually up.
|
||||
origin = publicOrigin ?? `http://${urlHost}:${actualPort}`;
|
||||
boundPort = actualPort;
|
||||
settle({ port: actualPort, url: origin });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
201
clients/web/server/public-address.ts
Normal file
201
clients/web/server/public-address.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
/**
|
||||
* Public addresses for the MCP Apps listeners (#1862): the URL the browser is
|
||||
* told to load the sandbox proxy from, and the origin a `_meta.ui.domain` app
|
||||
* document is served from, when either differs from the address the process
|
||||
* binds.
|
||||
*
|
||||
* ## Why an override exists
|
||||
*
|
||||
* Both listeners advertise a URL built from their own bind — `http://localhost:
|
||||
* 6275/sandbox` under a wildcard bind. That is right whenever the browser shares
|
||||
* the process's view of the network (a desktop, a container published on the
|
||||
* same port numbers, an SSH tunnel), and wrong behind a reverse proxy or an
|
||||
* ingress, where the browser reaches the listener at a public hostname and
|
||||
* frequently over HTTPS. The port knobs (`MCP_SANDBOX_PORT`,
|
||||
* `MCP_APP_ORIGIN_PORT`) cannot express a scheme or a hostname, so:
|
||||
*
|
||||
* - `MCP_SANDBOX_FULL_ADDRESS` — the sandbox proxy URL, e.g.
|
||||
* `https://inspector-sandbox.example.com/sandbox`. A bare origin gets
|
||||
* `/sandbox` appended, since that is the only path the listener serves.
|
||||
* - `MCP_APP_ORIGIN_FULL_ADDRESS` — the app-origin listener's origin, e.g.
|
||||
* `https://inspector-apps.example.com`. An origin only: documents are served
|
||||
* at `<origin>/app-document/<id>`, so a path would name nothing.
|
||||
*
|
||||
* Neither changes what the process listens on; the operator routes the public
|
||||
* address to the listener.
|
||||
*
|
||||
* ## Why the values are validated rather than passed through
|
||||
*
|
||||
* The sandbox origin is an isolation boundary, not merely an address. The MCP
|
||||
* Apps spec requires host ≠ sandbox origin, and today that holds structurally —
|
||||
* the listener is on its own port of the same process, so it cannot be
|
||||
* misconfigured. An operator-supplied address makes it configurable, and the
|
||||
* most natural reverse-proxy layout (`https://inspector.example.com/sandbox`
|
||||
* routed to `:6275`) is exactly the one that collapses it. The proxy page's own
|
||||
* self-test and its #2056 `src` check both fail closed, but their symptom is a
|
||||
* blank frame and a console throw; refusing here names the cause at boot.
|
||||
*
|
||||
* So a value is **refused** — warned and ignored, falling back to the
|
||||
* bind-derived address, matching the warn-and-drop precedent `ALLOWED_ORIGINS`
|
||||
* and `MCP_SANDBOX_PORT` set — when it is not an absolute `http(s)` URL, carries
|
||||
* credentials, a query, a fragment, a wildcard, or a bracketed IPv6 literal (not
|
||||
* a valid CSP host-source, and the sandbox origin is emitted into published
|
||||
* documents' `frame-ancestors`), or when its origin equals an Inspector UI
|
||||
* origin (or, for the app origin, the sandbox's). A plain-`http` address under
|
||||
* an `https` UI is only **warned**: the browser blocks it as mixed content, but
|
||||
* `ALLOWED_ORIGINS` can legitimately list both schemes at once, so it is not
|
||||
* provably wrong.
|
||||
*
|
||||
* Not `DANGEROUSLY_`-prefixed: it widens no bind and disables no check, and the
|
||||
* configurations that would be dangerous are rejected rather than renamed.
|
||||
*/
|
||||
|
||||
export const SANDBOX_FULL_ADDRESS_ENV = "MCP_SANDBOX_FULL_ADDRESS";
|
||||
export const APP_ORIGIN_FULL_ADDRESS_ENV = "MCP_APP_ORIGIN_FULL_ADDRESS";
|
||||
|
||||
/** The only path the sandbox listener serves (see `sandbox-controller.ts`). */
|
||||
const SANDBOX_PATH = "/sandbox";
|
||||
|
||||
/** A parsed address, or the reason it was refused. */
|
||||
type Parsed = { url: URL } | { reason: string };
|
||||
|
||||
/**
|
||||
* Shape checks shared by both addresses. Returns the reason for the first
|
||||
* failure, so the warning names one concrete thing to fix.
|
||||
*/
|
||||
function parsePublicUrl(raw: string): Parsed {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
return { reason: "it is not an absolute URL" };
|
||||
}
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return { reason: "it needs an http:// or https:// scheme" };
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
return { reason: "it must not carry credentials" };
|
||||
}
|
||||
// `new URL` accepts `*` in a hostname; it is never a reachable address.
|
||||
if (url.hostname.includes("*")) {
|
||||
return { reason: "it must not contain a wildcard" };
|
||||
}
|
||||
if (url.hostname.startsWith("[")) {
|
||||
return {
|
||||
reason:
|
||||
"a bracketed IPv6 literal is not a valid CSP host-source — use a hostname or an IPv4 address",
|
||||
};
|
||||
}
|
||||
if (url.search || url.hash) {
|
||||
return { reason: "it must not carry a query string or fragment" };
|
||||
}
|
||||
return { url };
|
||||
}
|
||||
|
||||
function warnIgnored(env: string, raw: string, reason: string): void {
|
||||
console.warn(
|
||||
`Ignoring ${env}="${raw}": ${reason}. Advertising the bind-derived address instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Warn when a plain-`http` address will be loaded by an `https` UI. Not a
|
||||
* refusal — see the header comment.
|
||||
*/
|
||||
function warnMixedContent(
|
||||
env: string,
|
||||
url: URL,
|
||||
allowedOrigins: readonly string[],
|
||||
): void {
|
||||
if (
|
||||
url.protocol === "http:" &&
|
||||
allowedOrigins.some((o) => o.startsWith("https:"))
|
||||
) {
|
||||
console.warn(
|
||||
`${env} is plain http (${url.origin}) but ALLOWED_ORIGINS includes an https origin: ` +
|
||||
`a browser on the https UI blocks it as mixed content and MCP Apps will not render there.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The public sandbox proxy URL from {@link SANDBOX_FULL_ADDRESS_ENV}, or
|
||||
* `undefined` when it is unset, blank, or refused (warned).
|
||||
*
|
||||
* @param allowedOrigins The resolved Inspector UI origins — the sandbox must
|
||||
* not share an origin with any of them.
|
||||
*/
|
||||
export function resolveSandboxPublicUrl(
|
||||
raw: string | undefined,
|
||||
allowedOrigins: readonly string[],
|
||||
): string | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
const parsed = parsePublicUrl(value);
|
||||
if ("reason" in parsed) {
|
||||
warnIgnored(SANDBOX_FULL_ADDRESS_ENV, value, parsed.reason);
|
||||
return undefined;
|
||||
}
|
||||
const { url } = parsed;
|
||||
// A bare origin names the listener, not the page; the page is `/sandbox`.
|
||||
if (url.pathname === "/") url.pathname = SANDBOX_PATH;
|
||||
if (allowedOrigins.includes(url.origin)) {
|
||||
warnIgnored(
|
||||
SANDBOX_FULL_ADDRESS_ENV,
|
||||
value,
|
||||
`its origin ${url.origin} is also an Inspector UI origin (ALLOWED_ORIGINS), and the MCP Apps ` +
|
||||
`sandbox must be on a different origin from its host — route it to its own hostname or port`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
warnMixedContent(SANDBOX_FULL_ADDRESS_ENV, url, allowedOrigins);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
/**
|
||||
* The public app-origin from {@link APP_ORIGIN_FULL_ADDRESS_ENV}, or
|
||||
* `undefined` when it is unset, blank, or refused (warned).
|
||||
*
|
||||
* @param allowedOrigins The resolved Inspector UI origins.
|
||||
* @param sandboxUrl The advertised sandbox URL when it is known at config time
|
||||
* (i.e. overridden). A bind-derived one is on its own port and cannot match.
|
||||
*/
|
||||
export function resolveAppOriginPublicOrigin(
|
||||
raw: string | undefined,
|
||||
allowedOrigins: readonly string[],
|
||||
sandboxUrl: string | undefined,
|
||||
): string | undefined {
|
||||
const value = raw?.trim();
|
||||
if (!value) return undefined;
|
||||
const parsed = parsePublicUrl(value);
|
||||
if ("reason" in parsed) {
|
||||
warnIgnored(APP_ORIGIN_FULL_ADDRESS_ENV, value, parsed.reason);
|
||||
return undefined;
|
||||
}
|
||||
const { url } = parsed;
|
||||
if (url.pathname !== "/") {
|
||||
warnIgnored(
|
||||
APP_ORIGIN_FULL_ADDRESS_ENV,
|
||||
value,
|
||||
"it must be an origin only, with no path — app documents are served at <origin>/app-document/<id>",
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
// Same-origin with the host or the proxy is precisely what the dedicated
|
||||
// origin must never be: the inner frame is granted `allow-same-origin` on
|
||||
// this path, so sharing either origin hands the app that realm.
|
||||
const trusted = sandboxUrl
|
||||
? [...allowedOrigins, new URL(sandboxUrl).origin]
|
||||
: allowedOrigins;
|
||||
if (trusted.includes(url.origin)) {
|
||||
warnIgnored(
|
||||
APP_ORIGIN_FULL_ADDRESS_ENV,
|
||||
value,
|
||||
`its origin ${url.origin} is also the Inspector UI's or the sandbox's, and a dedicated app ` +
|
||||
`origin must differ from both — route it to its own hostname or port`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
warnMixedContent(APP_ORIGIN_FULL_ADDRESS_ENV, url, allowedOrigins);
|
||||
return url.origin;
|
||||
}
|
||||
|
|
@ -29,6 +29,14 @@ export interface SandboxControllerOptions {
|
|||
* list (only a hand-constructed caller or a test) falls back to loopback-only.
|
||||
*/
|
||||
allowedOrigins?: string[];
|
||||
/**
|
||||
* The URL to advertise instead of the bind-derived one — the operator's
|
||||
* `MCP_SANDBOX_FULL_ADDRESS`, already validated by `public-address.ts`
|
||||
* (#1862). It changes what {@link SandboxController.getUrl} returns, never
|
||||
* what is bound; and it is only advertised once the listener is actually up,
|
||||
* so a failed bind still reports no sandbox rather than a dead URL.
|
||||
*/
|
||||
publicUrl?: string;
|
||||
}
|
||||
|
||||
// Loopback fallback. NB: no `http://[::1]:*` — a **bracketed IPv6 literal is not
|
||||
|
|
@ -86,11 +94,59 @@ export function sandboxFrameAncestors(allowedOrigins?: string[]): string {
|
|||
* derive it here rather than each rolling one.
|
||||
*/
|
||||
export function frameAncestorsDirective(origins?: string[]): string {
|
||||
const valid = (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
const valid = validHostSources(origins);
|
||||
const sources = valid.length > 0 ? valid : LOOPBACK_FRAME_ANCESTORS;
|
||||
return `frame-ancestors ${sources.join(" ")}`;
|
||||
}
|
||||
|
||||
/** The entries of `origins` that are well-formed CSP host-sources. */
|
||||
function validHostSources(origins?: string[]): string[] {
|
||||
return (origins ?? []).filter((o) => CSP_HOST_SOURCE.test(o));
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker in `static/sandbox_proxy.html` that {@link renderSandboxProxyHtml}
|
||||
* replaces, together with the `null` it stands in front of. Matching the pair
|
||||
* means an unrendered page is still valid JavaScript, and it falls back to the
|
||||
* loopback check rather than to accepting anything.
|
||||
*/
|
||||
export const EMBEDDER_ORIGINS_PLACEHOLDER =
|
||||
"/*INSPECTOR_EMBEDDER_ORIGINS*/ null";
|
||||
|
||||
/**
|
||||
* The JavaScript literal the proxy page's referrer check reads as its exact
|
||||
* embedder allow-list (#1862).
|
||||
*
|
||||
* It is derived from the SAME filtered list as {@link sandboxFrameAncestors},
|
||||
* so the referrer check and the `frame-ancestors` header admit one set. Before
|
||||
* this the page hardcoded `http://localhost|127.0.0.1`, which no `https://`
|
||||
* referrer can match — so the proxy threw on every HTTPS deployment regardless
|
||||
* of `ALLOWED_ORIGINS`. When nothing valid remains the literal is `null`, and
|
||||
* the page falls back to that loopback check, mirroring the header's own
|
||||
* loopback fallback.
|
||||
*
|
||||
* The value lands inside a `<script>`, so `<` is escaped: `CSP_HOST_SOURCE`
|
||||
* does not exclude it, and a `</script>` inside a string literal would close
|
||||
* the element regardless of the quotes around it.
|
||||
*/
|
||||
export function embedderOriginsLiteral(origins?: string[]): string {
|
||||
const valid = validHostSources(origins);
|
||||
if (valid.length === 0) return "null";
|
||||
return JSON.stringify(valid).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
/** Substitute the embedder allow-list into the proxy page's source. */
|
||||
export function renderSandboxProxyHtml(
|
||||
html: string,
|
||||
origins?: string[],
|
||||
): string {
|
||||
// Function-form replacement: a string replacement would interpret `$&` and
|
||||
// friends, and the literal is operator-derived.
|
||||
return html.replace(EMBEDDER_ORIGINS_PLACEHOLDER, () =>
|
||||
embedderOriginsLiteral(origins),
|
||||
);
|
||||
}
|
||||
|
||||
export interface SandboxController {
|
||||
start(): Promise<{ port: number; url: string }>;
|
||||
close(): Promise<void>;
|
||||
|
|
@ -156,9 +212,12 @@ export function createSandboxController(
|
|||
// `localhost` — a name resolves to one address family and would reintroduce
|
||||
// the #1951 split (web on IPv4, sandbox on IPv6) for any future call site
|
||||
// that omits `host`. Both call sites pass `config.sandboxHost` today.
|
||||
const { port, host = DEFAULT_BIND_HOST, allowedOrigins } = options;
|
||||
const { port, host = DEFAULT_BIND_HOST, allowedOrigins, publicUrl } = options;
|
||||
let server: Server | null = null;
|
||||
let sandboxUrl: string | null = null;
|
||||
// Tracked separately from the URL: an advertised public URL carries the
|
||||
// public port (or none at all), not the one this listener bound.
|
||||
let boundPort = 0;
|
||||
|
||||
// Defense-in-depth for the proxy page itself. Only `frame-ancestors` is set
|
||||
// here — fetch directives (`default-src`, `connect-src`, etc.) are
|
||||
|
|
@ -175,7 +234,10 @@ export function createSandboxController(
|
|||
let sandboxHtml: string;
|
||||
try {
|
||||
const sandboxHtmlPath = join(__dirname, "../static/sandbox_proxy.html");
|
||||
sandboxHtml = readFileSync(sandboxHtmlPath, "utf-8");
|
||||
sandboxHtml = renderSandboxProxyHtml(
|
||||
readFileSync(sandboxHtmlPath, "utf-8"),
|
||||
allowedOrigins,
|
||||
);
|
||||
} catch (e) {
|
||||
sandboxHtml =
|
||||
"<!DOCTYPE html><html><body>Sandbox not loaded: " +
|
||||
|
|
@ -186,8 +248,7 @@ export function createSandboxController(
|
|||
return {
|
||||
async start(): Promise<{ port: number; url: string }> {
|
||||
if (server && sandboxUrl) {
|
||||
const p = parseInt(new URL(sandboxUrl).port, 10);
|
||||
return { port: p, url: sandboxUrl };
|
||||
return { port: boundPort, url: sandboxUrl };
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
// Guard so a `listen` error followed by a (theoretically possible)
|
||||
|
|
@ -277,7 +338,10 @@ export function createSandboxController(
|
|||
const urlHost = isAllInterfacesHost(canonicalHost)
|
||||
? "localhost"
|
||||
: canonicalHost;
|
||||
sandboxUrl = `http://${urlHost}:${actualPort}/sandbox`;
|
||||
// An operator-supplied public URL (#1862) replaces the derived one
|
||||
// only now that the listener it routes to is actually up.
|
||||
sandboxUrl = publicUrl ?? `http://${urlHost}:${actualPort}/sandbox`;
|
||||
boundPort = actualPort;
|
||||
settle({ port: actualPort, url: sandboxUrl });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ export async function startHonoServer(
|
|||
port: config.sandboxPort,
|
||||
host: config.sandboxHost,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
publicUrl: config.sandboxPublicUrl,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// The dedicated origin apps declaring `_meta.ui.domain` are served from
|
||||
|
|
@ -54,6 +55,7 @@ export async function startHonoServer(
|
|||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
publicOrigin: config.appOriginPublicOrigin,
|
||||
embedderOrigins: appDocumentEmbedders(
|
||||
sandboxController.getUrl(),
|
||||
config.allowedOrigins,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
port: config.sandboxPort,
|
||||
host: config.sandboxHost,
|
||||
allowedOrigins: config.allowedOrigins,
|
||||
publicUrl: config.sandboxPublicUrl,
|
||||
});
|
||||
await sandboxController.start();
|
||||
// The dedicated origin apps declaring `_meta.ui.domain` are served from
|
||||
|
|
@ -77,6 +78,7 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
publicOrigin: config.appOriginPublicOrigin,
|
||||
embedderOrigins: appDocumentEmbedders(
|
||||
sandboxController.getUrl(),
|
||||
config.allowedOrigins,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ import { readInspectorVersionSafe } from "../../../core/node/version.ts";
|
|||
import { resolveSandboxPort } from "./sandbox-controller.js";
|
||||
import { resolveAppOriginPort } from "./app-origin-controller.js";
|
||||
import { resolveBindHostname } from "./resolve-bind-host.js";
|
||||
import {
|
||||
APP_ORIGIN_FULL_ADDRESS_ENV,
|
||||
resolveAppOriginPublicOrigin,
|
||||
resolveSandboxPublicUrl,
|
||||
SANDBOX_FULL_ADDRESS_ENV,
|
||||
} from "./public-address.js";
|
||||
import {
|
||||
canonicalUrlHost,
|
||||
isAllInterfacesHost,
|
||||
|
|
@ -74,6 +80,17 @@ export interface WebServerConfig {
|
|||
* address the sandbox uses.
|
||||
*/
|
||||
appOriginPort: number;
|
||||
/**
|
||||
* Public sandbox proxy URL advertised in place of the bind-derived one
|
||||
* (`MCP_SANDBOX_FULL_ADDRESS`, #1862), or undefined when unset or refused.
|
||||
* See `public-address.ts` for what is refused and why.
|
||||
*/
|
||||
sandboxPublicUrl?: string;
|
||||
/**
|
||||
* Public origin published app documents are served under
|
||||
* (`MCP_APP_ORIGIN_FULL_ADDRESS`, #1862), or undefined when unset or refused.
|
||||
*/
|
||||
appOriginPublicOrigin?: string;
|
||||
logger: Logger | undefined;
|
||||
/** When true, open browser after server starts. */
|
||||
autoOpen: boolean;
|
||||
|
|
@ -460,6 +477,22 @@ export function buildWebServerConfig(
|
|||
}
|
||||
})
|
||||
.filter((o): o is string => o !== null);
|
||||
const allowedOrigins = configuredOrigins?.length
|
||||
? configuredOrigins
|
||||
: defaultAllowedOrigins(hostname, port);
|
||||
|
||||
// Resolved after the allow-list because both are checked against it: neither
|
||||
// listener may share an origin with the Inspector page (#1862). The sandbox
|
||||
// goes first so the app origin can be checked against it too.
|
||||
const sandboxPublicUrl = resolveSandboxPublicUrl(
|
||||
process.env[SANDBOX_FULL_ADDRESS_ENV],
|
||||
allowedOrigins,
|
||||
);
|
||||
const appOriginPublicOrigin = resolveAppOriginPublicOrigin(
|
||||
process.env[APP_ORIGIN_FULL_ADDRESS_ENV],
|
||||
allowedOrigins,
|
||||
sandboxPublicUrl,
|
||||
);
|
||||
|
||||
return {
|
||||
port,
|
||||
|
|
@ -471,12 +504,12 @@ export function buildWebServerConfig(
|
|||
writable,
|
||||
initialServers,
|
||||
storageDir: process.env.MCP_STORAGE_DIR,
|
||||
allowedOrigins: configuredOrigins?.length
|
||||
? configuredOrigins
|
||||
: defaultAllowedOrigins(hostname, port),
|
||||
allowedOrigins,
|
||||
sandboxPort,
|
||||
sandboxHost: hostname,
|
||||
appOriginPort,
|
||||
sandboxPublicUrl,
|
||||
appOriginPublicOrigin,
|
||||
logger,
|
||||
autoOpen: resolveAutoOpen(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
160
clients/web/src/test/integration/server/public-address.test.ts
Normal file
160
clients/web/src/test/integration/server/public-address.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -76,8 +76,19 @@
|
|||
return allowList.join("; ");
|
||||
}
|
||||
|
||||
const ALLOWED_REFERRER_PATTERN =
|
||||
/^http:\/\/(localhost|127\.0\.0\.1)(:|\/|$)/;
|
||||
// The exact embedder origins this proxy accepts, injected by the sandbox
|
||||
// controller when it serves this page (see `embedderOriginsLiteral` in
|
||||
// server/sandbox-controller.ts). It is the same list the served
|
||||
// `frame-ancestors` header admits, so the two checks agree by
|
||||
// construction — an https or public-hostname Inspector page is accepted
|
||||
// exactly when the backend's ALLOWED_ORIGINS names it (#1862).
|
||||
//
|
||||
// `null` means the controller had no valid list (a hand-constructed
|
||||
// caller or a test); the header then falls back to the loopback family,
|
||||
// and so does this check.
|
||||
const ALLOWED_EMBEDDER_ORIGINS = /*INSPECTOR_EMBEDDER_ORIGINS*/ null;
|
||||
const LOOPBACK_EMBEDDER_PATTERN =
|
||||
/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
|
||||
|
||||
if (window.self === window.top) {
|
||||
throw new Error("This file is only to be used in an iframe sandbox.");
|
||||
|
|
@ -87,7 +98,22 @@
|
|||
throw new Error("No referrer, cannot validate embedding site.");
|
||||
}
|
||||
|
||||
if (!document.referrer.match(ALLOWED_REFERRER_PATTERN)) {
|
||||
// Compare the referrer's parsed ORIGIN, never the raw string: a pattern
|
||||
// over the full referrer is a prefix/substring match an attacker page's
|
||||
// path or query can be shaped to satisfy.
|
||||
let referrerOrigin = null;
|
||||
try {
|
||||
referrerOrigin = new URL(document.referrer).origin;
|
||||
} catch (e) {
|
||||
referrerOrigin = null;
|
||||
}
|
||||
|
||||
if (
|
||||
!referrerOrigin ||
|
||||
!(Array.isArray(ALLOWED_EMBEDDER_ORIGINS)
|
||||
? ALLOWED_EMBEDDER_ORIGINS.includes(referrerOrigin)
|
||||
: LOOPBACK_EMBEDDER_PATTERN.test(referrerOrigin))
|
||||
) {
|
||||
throw new Error(
|
||||
`Embedding domain not allowed in referrer ${document.referrer}. (Consider updating the validation logic to allow your domain.)`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ docker run --rm -p 127.0.0.1:6274:6274 -p 127.0.0.1:6275:6275 -p 127.0.0.1:6278:
|
|||
|
||||
Publish each on the **same port number** inside and out. The sandbox URL is handed to the browser via `/api/config` as `http://localhost:<container port>/sandbox`, so remapping it (`-p 9000:6275`) advertises a port the browser can't reach; use `-e MCP_SANDBOX_PORT=9000 -p 127.0.0.1:9000:9000` instead. The same holds for the app origin: the URL a published app document is served from is built from the port the container bound, so remap with `-e MCP_APP_ORIGIN_PORT=9001 -p 127.0.0.1:9001:9001` rather than with `-p` alone.
|
||||
|
||||
**Behind a reverse proxy or an ingress**, where the browser reaches the listeners at public hostnames rather than at `localhost`, the same-number rule is not enough: the advertised URLs name the container's bind. Set `MCP_SANDBOX_FULL_ADDRESS` (e.g. `https://inspector-sandbox.example.com/sandbox`) and, for apps declaring `_meta.ui.domain`, `MCP_APP_ORIGIN_FULL_ADDRESS` (e.g. `https://inspector-apps.example.com`), route each to its container port, and set `ALLOWED_ORIGINS` to the public Inspector origin. Each listener needs its own hostname or port — an address sharing the Inspector's origin is refused. See [Behind a reverse proxy](../clients/web/README.md#host-binding--the-origin-allow-list) for the full contract.
|
||||
|
||||
**Keep the `127.0.0.1:` prefix on the published port.** A bare `-p 6274:6274` publishes on **every host interface**, putting the Inspector on your local network. The container's `HOST=0.0.0.0` is a separate concern — it governs the _container's_ interfaces, not the host's — so the `DANGEROUSLY_BIND_ALL_INTERFACES` opt-in that guards a wildcard bind outside a container does not cover this. It matters more here than for an ordinary web app: the backend spawns processes on request, `GET /` embeds the API token into the served HTML, and a request arriving with **no** `Origin` header skips the origin allow-list entirely — so for any non-browser client the API token is the only guard. Publishing wider needs a real access-control boundary in front of the Inspector — a reverse proxy that authenticates, an SSH tunnel, a private network. Setting your own `MCP_INSPECTOR_API_TOKEN` does **not** substitute: `GET /` discloses whatever token is in use, so a custom one is harvested exactly as easily as a generated one.
|
||||
|
||||
**Keeping the servers you add.** The Inspector saves your server list to `$HOME/.mcp-inspector/mcp.json`, which in the image is `/home/node/.mcp-inspector/mcp.json` — inside the container's writable layer, so `--rm` discards it and every run starts with an empty list. Mount a volume there to keep it:
|
||||
|
|
|
|||
|
|
@ -282,6 +282,8 @@ Stdout is otherwise compatible: the default `text` format still pretty-prints th
|
|||
| — | `MCP_CATALOG_PATH` | Default catalog path |
|
||||
| — | `MCP_SANDBOX_PORT` | MCP Apps sandbox server port (fixed `6275` by default) |
|
||||
| — | `MCP_APP_ORIGIN_PORT` | Dedicated app-origin server port (fixed `6278` by default), used only by an MCP App whose UI resource declares `_meta.ui.domain` |
|
||||
| — | `MCP_SANDBOX_FULL_ADDRESS` | Public MCP Apps sandbox URL advertised instead of the bind-derived one, for a reverse proxy (#1862). The v2 counterpart of v1's `MCP_PROXY_FULL_ADDRESS`, but refused when it shares the Inspector's origin |
|
||||
| — | `MCP_APP_ORIGIN_FULL_ADDRESS` | Public origin for `_meta.ui.domain` app documents, for a reverse proxy (#1862) |
|
||||
| — | `MCP_STORAGE_DIR`, `MCP_INSPECTOR_OAUTH_STATE_PATH`, `MCP_CLIENT_CONFIG_PATH` | Storage and OAuth state locations |
|
||||
| — | `MCP_OAUTH_CALLBACK_URL` | CLI/TUI OAuth loopback callback |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue