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>
This commit is contained in:
cliffhall 2026-08-24 00:07:15 -04:00 • committed by GitHub
commit fafc8367b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 211 additions and 73 deletions

View file

@ -155,8 +155,11 @@ Inspector owns no domain infrastructure — it cannot serve
not parsed, matched, or reserved — declare whatever your production host
expects.
- The Inspector answers with a real HTTP origin on loopback:
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6276`** (beside the web
port `6274` and the sandbox port `6275`, so the three forward together).
`http://<host>:<MCP_APP_ORIGIN_PORT>`, default **`6278`** — in the same
`627x` family as the web port `6274` and the sandbox port `6275`, so the
three forward together. It is **not** `6276`: that is the fixed loopback
OAuth callback the CLI and TUI listen on, which OAuth apps pre-register and
which therefore cannot move. `6277` is skipped as v1's retired proxy port.
- The app document is served from there under an unguessable path, its
per-app CSP delivered as a real response **header** (stronger than the
`<meta>` the `srcdoc` path relies on) plus a `frame-ancestors` that admits
@ -186,7 +189,7 @@ default (opaque-origin) way and logs a console warning naming `_meta.ui.domain`.
Losing the real origin degrades what the app can reach; losing the app itself
would be worse.
**Forward `6276` too** if you need this off loopback (a container, a tunnel).
**Forward `6278` too** if you need this off loopback (a container, a tunnel).
As with the sandbox port, a taken port falls back to an OS-assigned one with a
loud warning — the app still renders, but the origin an app's backend was told
to allowlist is then wrong, which is what the warning tells you.

View file

@ -57,11 +57,22 @@ 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. `6276`
* sits next to the web (`6274`) and sandbox (`6275`) ports so the three forward
* together.
* 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 = 6276;
export const DEFAULT_APP_ORIGIN_PORT = 6278;
/** Documents kept at once. Oldest is evicted past this; see {@link publish}. */
const MAX_DOCUMENTS = 32;
@ -142,7 +153,7 @@ export interface AppOriginController {
/**
* 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 (`6276abc`) and the bound keeps an out-of-range
* `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 {

View file

@ -3489,7 +3489,7 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
// green while a domain-declaring elicitation app silently loses its real
// origin. Assert it is not merely present but actually reaches the lib.
publishAppDocumentMock.mockResolvedValue(
"http://127.0.0.1:6276/app-document/deadbeef",
"http://127.0.0.1:6278/app-document/deadbeef",
);
renderWithMantine(<App />);
@ -3510,7 +3510,7 @@ describe("App MCP App listed-resource metadata wiring (#2055)", () => {
html: "<p>app</p>",
csp: "default-src 'none'",
}),
).resolves.toBe("http://127.0.0.1:6276/app-document/deadbeef");
).resolves.toBe("http://127.0.0.1:6278/app-document/deadbeef");
expect(publishAppDocumentMock).toHaveBeenCalledWith(
{ html: "<p>app</p>", csp: "default-src 'none'" },
expect.objectContaining({ baseUrl: expect.any(String) }),

View file

@ -498,7 +498,7 @@ describe("createAppBridgeFactory", () => {
);
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6276/app-document/abc");
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource,
@ -517,7 +517,7 @@ describe("createAppBridgeFactory", () => {
const call = bridgeInstances[0].sendSandboxResourceReady.mock
.calls[0][0] as { html: string; src?: string };
expect(call.src).toBe("http://localhost:6276/app-document/abc");
expect(call.src).toBe("http://localhost:6278/app-document/abc");
// `html` still rides along: a proxy that ignores `src` renders the app
// rather than a blank frame.
expect(call.html).toBe(published.html);
@ -530,7 +530,7 @@ describe("createAppBridgeFactory", () => {
])("does not take the dedicated path for %s", async (_label, domain) => {
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6276/app-document/abc");
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi
@ -550,7 +550,7 @@ describe("createAppBridgeFactory", () => {
it("never publishes a resource that declares no domain", async () => {
const publishAppDocument = vi
.fn<(doc: { html: string; csp?: string }) => Promise<string | null>>()
.mockResolvedValue("http://localhost:6276/app-document/abc");
.mockResolvedValue("http://localhost:6278/app-document/abc");
const factory = createAppBridgeFactory({
getClient: () => fakeClient,
readResource: vi.fn().mockResolvedValue(uiResource("<h1>x</h1>")),

View file

@ -27,13 +27,13 @@ describe("publishAppDocument", () => {
const fetchFn = vi
.fn<typeof fetch>()
.mockResolvedValue(
jsonResponse({ url: "http://localhost:6276/app-document/abc" }),
jsonResponse({ url: "http://localhost:6278/app-document/abc" }),
);
const url = await publishAppDocument(
{ html: "<p>app</p>", csp: "default-src 'none'" },
{ baseUrl: "http://localhost:6274", authToken: "tok", fetchFn },
);
expect(url).toBe("http://localhost:6276/app-document/abc");
expect(url).toBe("http://localhost:6278/app-document/abc");
expect(fetchFn).toHaveBeenCalledWith(
"http://localhost:6274/api/app-document",
expect.objectContaining({

View file

@ -37,13 +37,13 @@ describe("createRemoteApp POST /api/app-document", () => {
it("publishes the document and returns the URL", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://localhost:6276/app-document/abc" });
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
const res = await appWith(publish).request(
post({ html: "<h1>hi</h1>", csp: "default-src 'none'" }),
);
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({
url: "http://localhost:6276/app-document/abc",
url: "http://localhost:6278/app-document/abc",
});
expect(publish).toHaveBeenCalledWith({
html: "<h1>hi</h1>",
@ -54,7 +54,7 @@ describe("createRemoteApp POST /api/app-document", () => {
it("accepts a document with no csp", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://localhost:6276/app-document/abc" });
.mockReturnValue({ url: "http://localhost:6278/app-document/abc" });
const res = await appWith(publish).request(post({ html: "<h1>hi</h1>" }));
expect(res.status).toBe(200);
expect(publish).toHaveBeenCalledWith({
@ -112,6 +112,46 @@ describe("createRemoteApp POST /api/app-document", () => {
expect(publish).not.toHaveBeenCalled();
});
it.each([
["a CR/LF-carrying csp", "default-src 'none'\r\nX-Evil: 1"],
["a NUL-carrying csp", "default-src 'none'\u0000"],
])("rejects %s with 400", async (_label, csp) => {
// The controller hands `csp` to `res.writeHead()` verbatim. Node throws
// ERR_INVALID_CHAR there SYNCHRONOUSLY, inside the handler for a later,
// unrelated request — so accepting one here turns a bad publish into a
// thrown listener rather than a 400 anyone can see.
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(post({ html: "<p>x</p>", csp }));
expect(res.status).toBe(400);
expect(publish).not.toHaveBeenCalled();
});
it("rejects an oversized csp with 413 — it is retained per document", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(
post({ html: "<p>x</p>", csp: "a".repeat(8 * 1024 + 1) }),
);
expect(res.status).toBe(413);
expect(publish).not.toHaveBeenCalled();
});
it("rejects an oversized BODY before parsing it", async () => {
// Distinct from the document check below, which only runs once the whole
// request has been buffered and parsed — and only bounds `html`.
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()
.mockReturnValue({ url: "http://x/y" });
const res = await appWith(publish).request(
post(`{"junk":"${"x".repeat(24 * 1024 * 1024 + 1024)}"}`),
);
expect(res.status).toBe(413);
expect(publish).not.toHaveBeenCalled();
});
it("rejects an oversized document with 413 rather than pinning it in memory", async () => {
const publish = vi
.fn<(doc: { html: string; csp?: string }) => { url: string } | null>()

View file

@ -21,6 +21,7 @@ import {
resolveAppOriginPort,
appDocumentEmbedders,
} from "../../../../server/app-origin-controller.js";
import { RUNNER_OAUTH_CALLBACK_DEFAULT_PORT } from "@inspector/core/auth/node/runner-oauth-callback.js";
describe("resolveAppOriginPort", () => {
const saved = process.env.MCP_APP_ORIGIN_PORT;
@ -34,6 +35,19 @@ describe("resolveAppOriginPort", () => {
expect(resolveAppOriginPort()).toBe(DEFAULT_APP_ORIGIN_PORT);
});
it("does not default to the runners' fixed OAuth callback port", () => {
// Both listeners are fixed-by-default and both live in the 627x family, so
// the next free number is the obvious pick for each — which is how they
// collided. This one can move (it warns and retries on a dynamic port);
// the OAuth callback cannot, because apps pre-register
// `http://127.0.0.1:<that port>/oauth/callback`. A `--web` holding the
// port would therefore break a later `--cli`/`--tui` OAuth flow, and the
// handoff recipe runs those side by side.
expect(DEFAULT_APP_ORIGIN_PORT).not.toBe(
RUNNER_OAUTH_CALLBACK_DEFAULT_PORT,
);
});
it("honors a valid MCP_APP_ORIGIN_PORT, including an explicit 0", () => {
process.env.MCP_APP_ORIGIN_PORT = "9200";
expect(resolveAppOriginPort()).toBe(9200);
@ -41,7 +55,7 @@ describe("resolveAppOriginPort", () => {
expect(resolveAppOriginPort()).toBe(0);
});
it.each(["6276abc", "70000", "-1", "nope", " "])(
it.each(["6278abc", "70000", "-1", "nope", " "])(
"warns and falls back for an invalid value %j",
(value) => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@ -357,7 +371,7 @@ describe("createAppOriginController", () => {
address: () => ({
address: "127.0.0.1",
family: "IPv4",
port: 6276,
port: 6278,
}),
close: (cb?: () => void) => cb?.(),
});
@ -368,11 +382,11 @@ describe("createAppOriginController", () => {
try {
const mod = await import("../../../../server/app-origin-controller.js");
const c = mod.createAppOriginController({
port: 6276,
port: 6278,
host: "127.0.0.1",
});
await c.start();
expect(c.getOrigin()).toBe("http://127.0.0.1:6276");
expect(c.getOrigin()).toBe("http://127.0.0.1:6278");
expect(c.publish({ html: "<p>x</p>" })).not.toBeNull();
emitter.emit(

View file

@ -303,13 +303,13 @@ describe("buildWebServerConfigFromEnv", () => {
expect(buildWebServerConfigFromEnv().sandboxPort).toBe(9100);
});
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6276", () => {
it("resolves appOriginPort from MCP_APP_ORIGIN_PORT, defaulting to 6278", () => {
// The dedicated app origin (#2056). Deliberately NOT sharing the sandbox's
// SERVER_PORT fallback: it is a different listener and must be pinnable
// (and forwardable) on its own.
delete process.env.MCP_APP_ORIGIN_PORT;
process.env.SERVER_PORT = "9100";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6276);
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(6278);
process.env.MCP_APP_ORIGIN_PORT = "9300";
expect(buildWebServerConfigFromEnv().appOriginPort).toBe(9300);
});