inspector/clients/web/server/app-origin-controller.ts
cliffhall fafc8367b8
fix(web): move the app origin off 6276, bound the publish route
Copilot review round 2 on #2100, plus a collision the review did not find.

The default app-origin port was 6276 — which is already
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT, the CLI/TUI loopback OAuth callback.
That port is fixed precisely so an OAuth app can pre-register
http://127.0.0.1:6276/oauth/callback, which makes it the one listener here
that cannot move when something takes it first. A running --web would hold
it and break a later --cli/--tui OAuth flow at its registered redirect URI,
and docs/mcp-app-review.md has those two running side by side. This
listener has a documented dynamic fallback, so this listener yields: the
default is now 6278 (6277 skipped — v1's retired proxy port, which the
migration guide tells people to stop forwarding). A test asserts the two
defaults stay distinct.

Review comments:

- POST /api/app-document now applies hono's bodyLimit BEFORE parsing. The
  8MiB check ran after c.req.json() had buffered and parsed the whole
  request, and bounded only `html` — so bulk in `csp`, or in keys the route
  never reads, was fully materialized before the 413.
- `csp` is validated as an HTTP header value and length-bounded. The
  controller hands it to res.writeHead() verbatim, where Node throws
  ERR_INVALID_CHAR synchronously — inside the handler for a later,
  unrelated request, outside this route's error handling.
- The app-origin port now propagates everywhere the other two do: the CLI
  --print-handoff port-forward command (and its test), the Dockerfile's ENV
  and EXPOSE, and the remote-review SSH recipe in docs/mcp-app-review.md.
  Without it an app declaring _meta.ui.domain is unreachable through every
  documented remote workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-24 08:45:40 -04:00

354 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* App-origin server: the dedicated origin an MCP App gets when its UI resource
* declares `_meta.ui.domain` (#2056).
*
* ## Why a third listener exists
*
* By default the Inspector renders an App by handing its HTML to the sandbox
* proxy as `srcdoc`, in an iframe sandboxed **without** `allow-same-origin`.
* That is the isolation model #1565 chose, and it is the right default — but it
* gives the app document an *opaque* origin, so every request it makes carries
* `Origin: null`. An app whose backend allowlists origins (CORS, an OAuth
* callback, an API-key allowlist) therefore cannot work, which is exactly what
* `_meta.ui.domain` exists to solve: the spec lets a server ask its host for a
* stable, dedicated origin.
*
* `domain` is explicitly **host-dependent** — "the format and validation rules
* for this field are determined by each host" — and the Inspector owns no
* domain infrastructure. What it *can* provide is a real, stable HTTP origin on
* loopback. So a UI resource that declares any non-empty `domain` opts into
* being served from this listener instead of `srcdoc`, and its requests carry a
* real `Origin: http://<host>:<port>`.
*
* ## One shared origin, not one per domain
*
* Every domain-declaring app is served from this single port, path-keyed by an
* unguessable document id. That is a deliberate trade: it delivers the property
* the spec field is *for* (a real, allowlistable origin) without minting a port
* — or a DNS name — per app. The consequence is that this origin is **not** a
* per-app isolation boundary: two apps served here share `localStorage`,
* `sessionStorage`, and cookies for it. They remain isolated from the sandbox
* proxy and from the Inspector itself, which are different origins.
*
* ## Why granting `allow-same-origin` here is not a regression
*
* The inner frame is sandboxed with `allow-same-origin` only on this path,
* which is what makes the origin real rather than opaque. The #1565 rationale —
* "the app cannot touch the proxy's DOM, so it cannot bypass its own CSP by
* executing in the parent's realm" — still holds, because this listener is on
* its own port and is therefore cross-origin to both the proxy and the app
* page. Same-origin policy blocks the reach either way.
*
* The per-app CSP is additionally delivered as a real response **header** here,
* which is stronger than the `<meta>` the `srcdoc` path must rely on: a header
* applies to the document before any of its bytes are parsed and cannot be
* displaced by anything in the app's own markup.
*/
import { createServer, type Server } from "node:http";
import { randomBytes } from "node:crypto";
import {
canonicalUrlHost,
isAllInterfacesHost,
} from "../../../core/node/hostUrl.ts";
import { DEFAULT_BIND_HOST } from "./resolve-bind-host.js";
import { frameAncestorsDirective } from "./sandbox-controller.js";
/**
* The default app-origin listen port — **fixed**, for the same reason
* `DEFAULT_SANDBOX_PORT` is: a port that changes per run can never be named in
* a dev container's `forwardPorts`, a `docker run -p`, or an SSH tunnel.
*
* **Not 6276, and not 6277.** `6276` is already taken, by the fixed loopback
* OAuth callback the CLI and TUI listen on
* (`RUNNER_OAUTH_CALLBACK_DEFAULT_PORT`) — fixed precisely so an OAuth app can
* pre-register `http://127.0.0.1:6276/oauth/callback`, which means it is the
* one port here that CANNOT move when something else takes it first. Binding it
* would let a running `--web` (started at login, holding the port) break a
* later `--cli`/`--tui` OAuth flow at its registered redirect URI — and the
* handoff recipe in `docs/mcp-app-review.md` has those two running side by
* side. Of the two listeners this listener is the one with a documented
* dynamic fallback, so it is the one that yields. `6277` is skipped because it
* was v1's MCP proxy port, which the migration guide tells people to stop
* forwarding; reusing it would make that instruction wrong.
*/
export const DEFAULT_APP_ORIGIN_PORT = 6278;
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
const MAX_DOCUMENTS = 32;
/**
* How long a published document stays fetchable. An iframe fetches its `src`
* once, so this only has to outlive the gap between publishing and the frame
* loading — but a generous window keeps a reload (React StrictMode, a devtools
* "reload frame") working rather than blanking the app.
*/
const DOCUMENT_TTL_MS = 60 * 60 * 1000;
/**
* The origins that may appear in a published document's ancestor chain, for
* {@link AppOriginControllerOptions.embedderOrigins}.
*
* There are **two**, and getting this wrong blocks the frame outright: CSP's
* `frame-ancestors` is checked against *every* ancestor, not just the immediate
* parent. A published document is framed by the sandbox proxy, which is itself
* framed by the Inspector app page — so the directive has to admit the proxy's
* origin **and** the app's, which is exactly the backend's origin allow-list.
*
* `undefined` when neither is known, which makes the directive fall back to the
* loopback family rather than to `'none'`.
*/
export function appDocumentEmbedders(
sandboxUrl: string | null | undefined,
allowedOrigins?: string[],
): string[] | undefined {
const origins: string[] = [];
if (sandboxUrl) {
try {
origins.push(new URL(sandboxUrl).origin);
} catch {
/* v8 ignore next -- the sandbox controller only ever emits a URL it built
itself; an unparseable value would be a bug there, not input. */
}
}
origins.push(...(allowedOrigins ?? []));
return origins.length > 0 ? origins : undefined;
}
export interface AppOriginControllerOptions {
/** Port to bind (0 = dynamic). */
port: number;
/** Host to bind (default {@link DEFAULT_BIND_HOST}). */
host?: string;
/**
* The origin(s) allowed to frame a published document — in practice the
* sandbox proxy's own origin, since the proxy is what embeds the inner
* iframe. Malformed entries are dropped and an empty result falls back to
* loopback, exactly as the sandbox proxy's own `frame-ancestors` does.
*/
embedderOrigins?: string[];
}
/** A document handed to {@link AppOriginController.publish}. */
export interface AppDocument {
/** The fully-wrapped HTML the sandbox would otherwise have received. */
html: string;
/** The per-app CSP policy string, delivered as a response header. */
csp?: string;
}
export interface AppOriginController {
start(): Promise<{ port: number; url: string }>;
close(): Promise<void>;
/** The origin this listener serves on, or null when it never bound. */
getOrigin(): string | null;
/**
* Store a document and return the absolute URL the sandbox proxy should load
* it from, or `null` when this listener isn't running (the caller then falls
* back to the default `srcdoc` path rather than failing the render).
*/
publish(doc: AppDocument): { url: string } | null;
}
/**
* A usable listen port from a raw env value, or `undefined` if it isn't a plain
* integer in `0`–`65535`. Mirrors `sandbox-controller`'s parser: `^\d+$` rejects
* `parseInt`'s partial parses (`6278abc`) and the bound keeps an out-of-range
* value from reaching `server.listen`, which throws synchronously.
*/
function parseListenPort(raw: string | undefined): number | undefined {
const v = raw?.trim();
if (!v || !/^\d+$/.test(v)) return undefined;
const n = parseInt(v, 10);
return n <= 65535 ? n : undefined;
}
/**
* Resolve the app-origin port from env: `MCP_APP_ORIGIN_PORT` →
* {@link DEFAULT_APP_ORIGIN_PORT}. A set-but-invalid value is warned and falls
* through rather than crashing the boot, matching `resolveSandboxPort`.
*/
export function resolveAppOriginPort(): number {
const parsed = parseListenPort(process.env.MCP_APP_ORIGIN_PORT);
if (parsed === undefined && process.env.MCP_APP_ORIGIN_PORT?.trim()) {
console.warn(
`Ignoring invalid MCP_APP_ORIGIN_PORT="${process.env.MCP_APP_ORIGIN_PORT}" (need an integer 0–65535); falling back.`,
);
}
return parsed ?? DEFAULT_APP_ORIGIN_PORT;
}
/** The path a published document is served at, given its id. */
const DOC_PATH_PREFIX = "/app-document/";
/** `/app-document/<id>` → `<id>`, or null for any other request target. */
export function parseDocumentId(url: string | undefined): string | null {
if (!url || !url.startsWith(DOC_PATH_PREFIX)) return null;
// Drop a query string: an app's own URL may carry one and it is not part of
// the id. Anything with a further path segment is not a document target.
const id = url.slice(DOC_PATH_PREFIX.length).split(/[?#]/)[0];
return /^[0-9a-f]{32}$/.test(id ?? "") ? (id ?? null) : null;
}
interface StoredDocument extends AppDocument {
expiresAt: number;
}
export function createAppOriginController(
options: AppOriginControllerOptions,
): AppOriginController {
// 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;
let server: Server | null = null;
let origin: string | null = null;
// Insertion-ordered, so the first key is the oldest entry.
const documents = new Map<string, StoredDocument>();
const FRAME_ANCESTORS = frameAncestorsDirective(embedderOrigins);
/** Drop everything past its TTL. Cheap: the map is bounded by MAX_DOCUMENTS. */
function evictExpired(now: number): void {
for (const [id, doc] of documents) {
if (doc.expiresAt <= now) documents.delete(id);
}
}
return {
async start(): Promise<{ port: number; url: string }> {
if (server && origin) {
return { port: parseInt(new URL(origin).port, 10), url: origin };
}
return new Promise((resolve) => {
let settled = false;
const settle = (value: { port: number; url: string }) => {
/* v8 ignore next -- defensive double-settle guard: `error` and
`listening` are mutually exclusive for a TCP listen. */
if (settled) return;
settled = true;
resolve(value);
};
server = createServer((req, res) => {
const id = req.method === "GET" ? parseDocumentId(req.url) : null;
const doc = id ? documents.get(id) : undefined;
if (!doc || doc.expiresAt <= Date.now()) {
if (id) documents.delete(id);
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not Found");
return;
}
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Cache-Control": "no-store, no-cache, must-revalidate",
Pragma: "no-cache",
// `nosniff` matters more here than on a page we authored: these
// bytes are server-supplied and untrusted.
"X-Content-Type-Options": "nosniff",
// The per-app policy as a real header. It is the same policy baked
// into the document's <meta>; delivering both is deliberate —
// multiple policies intersect, and two copies of one policy
// intersect to itself, so this can only ever be at least as strict.
"Content-Security-Policy": doc.csp
? `${doc.csp}; ${FRAME_ANCESTORS}`
: FRAME_ANCESTORS,
});
res.end(doc.html);
});
// Same fallback as the sandbox controller: a fixed default port can
// collide (a second Inspector), and losing the listener would take
// dedicated-origin rendering down. Retry once on an OS-assigned port,
// loudly — whoever pinned the port to forward it needs to know.
let retriedDynamic = false;
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
if (!retriedDynamic && port !== 0) {
retriedDynamic = true;
console.warn(
`App origin: port ${port} in use; falling back to an OS-assigned port. ` +
`Apps declaring _meta.ui.domain will still render, but the origin ` +
`they are served from is no longer predictable — set ` +
`MCP_APP_ORIGIN_PORT to a free one if your app's backend ` +
`allowlists it.`,
);
server!.listen(0, host);
return;
}
console.error(
`App origin: port ${port || "dynamic"} in use. Apps declaring _meta.ui.domain will fall back to an opaque origin.`,
);
} else {
console.error("App origin server error:", err);
}
// Degrade rather than reject: `publish` then returns null and the
// renderer keeps working on the default srcdoc path. Clearing
// `origin` is what makes that true — `publish` gates on it, not on
// `server`, so an error arriving AFTER a successful listen would
// otherwise keep minting URLs on a dead listener and the browser
// would never take the promised fallback. Dropping the documents
// with it releases their memory, since nothing can fetch them now.
server = null;
origin = null;
documents.clear();
settle({ port: 0, url: "" });
});
server.listen(port, host, () => {
const addr = server!.address();
const actualPort =
typeof addr === "object" && addr !== null && "port" in addr
? addr.port
: /* v8 ignore next -- unreachable for a TCP listen; `address()`
returns AddressInfo. */
(addr as unknown as number);
// A wildcard bind isn't reachable as `http://0.0.0.0:PORT`, but it
// does serve loopback — advertise `localhost` there, and otherwise
// the same canonical host the origin allow-list emits.
const canonicalHost = canonicalUrlHost(host);
const urlHost = isAllInterfacesHost(canonicalHost)
? "localhost"
: canonicalHost;
origin = `http://${urlHost}:${actualPort}`;
settle({ port: actualPort, url: origin });
});
});
},
async close(): Promise<void> {
documents.clear();
if (!server) return;
return new Promise((resolve) => {
server!.close(() => {
server = null;
origin = null;
resolve();
});
});
},
getOrigin(): string | null {
return origin;
},
publish(doc: AppDocument): { url: string } | null {
if (!origin) return null;
const now = Date.now();
evictExpired(now);
// Bound the map even if nothing has expired: drop the oldest entry. An
// evicted document's frame has long since loaded, and the alternative is
// an unbounded store fed by every app the user opens in a session.
while (documents.size >= MAX_DOCUMENTS) {
const oldest = documents.keys().next();
/* v8 ignore next -- `size >= MAX_DOCUMENTS` guarantees a first key. */
if (oldest.done) break;
documents.delete(oldest.value);
}
const id = randomBytes(16).toString("hex");
documents.set(id, { ...doc, expiresAt: now + DOCUMENT_TTL_MS });
return { url: `${origin}${DOC_PATH_PREFIX}${id}` };
},
};
}