test(web): prove the dedicated app origin end to end
`frame-ancestors` is checked against every ancestor, not just the parent. A published app document is framed by the sandbox proxy, which is framed by the Inspector page — so admitting only the proxy's origin blocked the frame outright (it rendered as a chrome-error frame that never reached the bridge). `appDocumentEmbedders` now admits both. That failure was invisible to every unit test, so `smoke:web:app` grows a second phase: the same demo app declaring `_meta.ui.domain`, driven through the same connect → open → ready chain, then asserting the two facts that only hold on the dedicated path — the inner frame was navigated to a published document, and its own `location.origin` is a real http origin rather than "null". Both phases end at `data-app-status="ready"`, so nothing short of reading the frame's origin can tell them apart. Adds `test-servers/configs/mcp-app-domain-http.json` (and a `domain` param on the `mcp_app_demo_widget` preset) as the showcase server for both the smoke and hand testing. 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
ec40cf5531
commit
8e8c1bbad9
3 changed files with 200 additions and 34 deletions
|
|
@ -75,23 +75,33 @@ const MAX_DOCUMENTS = 32;
|
|||
const DOCUMENT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* The sandbox proxy's origin as a one-entry list for
|
||||
* {@link AppOriginControllerOptions.embedderOrigins}, or `undefined` when the
|
||||
* sandbox never bound (its controller degrades to a null URL). Passing
|
||||
* `undefined` is what makes the app-origin `frame-ancestors` fall back to
|
||||
* loopback rather than to `'none'`, which would block the frame outright.
|
||||
* 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 sandboxOriginList(
|
||||
export function appDocumentEmbedders(
|
||||
sandboxUrl: string | null | undefined,
|
||||
allowedOrigins?: string[],
|
||||
): string[] | undefined {
|
||||
if (!sandboxUrl) return undefined;
|
||||
try {
|
||||
return [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. */
|
||||
return 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 {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { formatHostForUrl } from "../../../core/node/hostUrl.ts";
|
|||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import {
|
||||
createAppOriginController,
|
||||
sandboxOriginList,
|
||||
appDocumentEmbedders,
|
||||
} from "./app-origin-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
|
|
@ -54,7 +54,10 @@ export async function startHonoServer(
|
|||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
embedderOrigins: sandboxOriginList(sandboxController.getUrl()),
|
||||
embedderOrigins: appDocumentEmbedders(
|
||||
sandboxController.getUrl(),
|
||||
config.allowedOrigins,
|
||||
),
|
||||
});
|
||||
await appOriginController.start();
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import { createRemoteApp } from "../../../core/mcp/remote/node/server.ts";
|
|||
import { createSandboxController } from "./sandbox-controller.js";
|
||||
import {
|
||||
createAppOriginController,
|
||||
sandboxOriginList,
|
||||
appDocumentEmbedders,
|
||||
} from "./app-origin-controller.js";
|
||||
import { injectAuthToken } from "./inject-auth-token.js";
|
||||
import type { WebServerConfig } from "./web-server-config.js";
|
||||
|
|
@ -77,7 +77,10 @@ export function honoMiddlewarePlugin(config: WebServerConfig): Plugin {
|
|||
const appOriginController = createAppOriginController({
|
||||
port: config.appOriginPort,
|
||||
host: config.sandboxHost,
|
||||
embedderOrigins: sandboxOriginList(sandboxController.getUrl()),
|
||||
embedderOrigins: appDocumentEmbedders(
|
||||
sandboxController.getUrl(),
|
||||
config.allowedOrigins,
|
||||
),
|
||||
});
|
||||
await appOriginController.start();
|
||||
// Resolved before the API is built so `/api/config` and the banner
|
||||
|
|
|
|||
Loading…
Reference in a new issue