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

View file

@ -24,6 +24,7 @@ import type { LogEvent } from "pino";
import { Hono } from "hono";
import type { Context, Env, Next } from "hono";
import { streamSSE } from "hono/streaming";
import { bodyLimit } from "hono/body-limit";
import { watch as chokidarWatch, type FSWatcher } from "chokidar";
import { createTransportNode } from "../../node/transport.js";
import { createProxyFetch } from "../../node/proxyFetch.js";
@ -306,6 +307,41 @@ export interface RemoteServerOptions {
*/
const MAX_APP_DOCUMENT_CHARS = 8 * 1024 * 1024;
/**
* Upper bound on the request BODY, in bytes, enforced by `bodyLimit` *before*
* anything is parsed. {@link MAX_APP_DOCUMENT_CHARS} alone is not enough: it is
* checked after `c.req.json()` has already buffered and parsed the whole
* request, so an unbounded body — or one whose bulk sits in `csp` or in keys
* the route never reads — is fully materialized in memory before being
* rejected. Generous relative to the document bound because JSON escaping
* inflates the payload (`<` stays one byte, but a `"` or a newline becomes
* two, and non-BMP text more), so this must not reject a document the char
* check would accept.
*/
const MAX_APP_DOCUMENT_BODY_BYTES = 24 * 1024 * 1024;
/**
* Upper bound on the per-app CSP policy string. Unlike the document, this is
* retained for the life of the published entry, and a real policy is a few
* hundred characters — an app pushing more is not describing sources.
*/
const MAX_APP_CSP_CHARS = 8 * 1024;
/**
* Whether a string is safe to emit as an HTTP header VALUE.
*
* The published `csp` is handed to `res.writeHead()` verbatim by the app-origin
* controller. Node validates header values there and throws
* `ERR_INVALID_CHAR` **synchronously inside the request handler** — outside
* this route's error handling, on a request this route is not even part of —
* so a CR/LF or NUL accepted here surfaces later as a thrown listener rather
* than a 400. Restrict to tab plus printable ASCII, which is stricter than RFC
* 9110 field-value grammar and is all a CSP policy needs.
*/
function isHeaderSafeValue(value: string): boolean {
return !/[^\t\x20-\x7e]/.test(value);
}
export interface CreateRemoteAppResult {
/** The Hono app */
app: Hono;
@ -744,44 +780,61 @@ export function createRemoteApp(
* listener on its own port, which is what makes the app's origin real
* instead of opaque.
*/
app.post("/api/app-document", async (c) => {
const publish = options.publishAppDocument;
if (!publish) {
return c.json({ error: "App-origin hosting is not available" }, 503);
}
let parsed: unknown;
try {
parsed = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
// `null` and `[1,2]` are both valid JSON, so `c.req.json()` resolving is
// not proof there is an object to destructure — and destructuring `null`
// here would throw OUTSIDE the try above, turning a malformed body into a
// 500 instead of the 400 every other bad shape gets.
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return c.json({ error: "Invalid JSON body" }, 400);
}
const { html, csp } = parsed as { html?: unknown; csp?: unknown };
if (typeof html !== "string" || html === "") {
return c.json({ error: "Missing html" }, 400);
}
if (csp !== undefined && typeof csp !== "string") {
return c.json({ error: "csp must be a string" }, 400);
}
if (html.length > MAX_APP_DOCUMENT_CHARS) {
return c.json({ error: "Document too large" }, 413);
}
const published = publish({ html, csp });
if (!published) {
return c.json({ error: "App-origin hosting is not available" }, 503);
}
return c.json(published);
});
app.post(
"/api/app-document",
// Before the parse, not after: see MAX_APP_DOCUMENT_BODY_BYTES.
bodyLimit({
maxSize: MAX_APP_DOCUMENT_BODY_BYTES,
onError: (c) => c.json({ error: "Document too large" }, 413),
}),
async (c) => {
const publish = options.publishAppDocument;
if (!publish) {
return c.json({ error: "App-origin hosting is not available" }, 503);
}
let parsed: unknown;
try {
parsed = await c.req.json();
} catch {
return c.json({ error: "Invalid JSON body" }, 400);
}
// `null` and `[1,2]` are both valid JSON, so `c.req.json()` resolving is
// not proof there is an object to destructure — and destructuring `null`
// here would throw OUTSIDE the try above, turning a malformed body into a
// 500 instead of the 400 every other bad shape gets.
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
return c.json({ error: "Invalid JSON body" }, 400);
}
const { html, csp } = parsed as { html?: unknown; csp?: unknown };
if (typeof html !== "string" || html === "") {
return c.json({ error: "Missing html" }, 400);
}
if (csp !== undefined && typeof csp !== "string") {
return c.json({ error: "csp must be a string" }, 400);
}
if (csp !== undefined && !isHeaderSafeValue(csp)) {
// Rejected here rather than sanitized: a policy carrying a control
// character is not a policy with a stray byte in it, it is a caller
// doing something this route has no honest interpretation of.
return c.json({ error: "csp is not a valid header value" }, 400);
}
if (html.length > MAX_APP_DOCUMENT_CHARS) {
return c.json({ error: "Document too large" }, 413);
}
if (csp !== undefined && csp.length > MAX_APP_CSP_CHARS) {
return c.json({ error: "csp too large" }, 413);
}
const published = publish({ html, csp });
if (!published) {
return c.json({ error: "App-origin hosting is not available" }, 503);
}
return c.json(published);
},
);
app.post("/api/mcp/connect", async (c) => {
let body: RemoteConnectRequest;