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
1 changed files with 211 additions and 73 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue