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:
parent
fa441da85f
commit
fafc8367b8
8 changed files with 211 additions and 73 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) }),
|
||||
|
|
|
|||
|
|
@ -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>")),
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue