diff --git a/src/gateway/mcp-app-sandbox-http.test.ts b/src/gateway/mcp-app-sandbox-http.test.ts index 316764f7d13e..20e8714c4be8 100644 --- a/src/gateway/mcp-app-sandbox-http.test.ts +++ b/src/gateway/mcp-app-sandbox-http.test.ts @@ -1,4 +1,5 @@ import type { IncomingMessage } from "node:http"; +import type { AddressInfo } from "node:net"; import { describe, expect, it } from "vitest"; import { buildMcpAppSandboxPath } from "../agents/mcp-app-sandbox.js"; import { createSandboxHostHttpServer } from "./mcp-app-sandbox-http.js"; @@ -12,6 +13,25 @@ function request(url: string, method: "GET" | "HEAD" | "POST" = "GET") { return { res, end, setHeader }; } +async function withSandboxHost(run: (origin: string) => Promise): Promise { + const server = createSandboxHostHttpServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address() as AddressInfo; + try { + await run(`http://127.0.0.1:${address.port}`); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + describe("MCP App sandbox HTTP origin", () => { it("serves only the proxy endpoint with metadata-derived CSP", () => { const result = request( @@ -80,4 +100,50 @@ describe("MCP App sandbox HTTP origin", () => { expect.stringContaining("connect-src https://xn--bcher-kva.example"), ); }); + + it.each([ + { + label: "sandbox HTML", + path: buildMcpAppSandboxPath(), + statusCode: 200, + }, + { + label: "missing path", + path: "/missing", + statusCode: 404, + }, + { + label: "malformed policy", + path: `${buildMcpAppSandboxPath()}?csp=not-json`, + statusCode: 400, + }, + ])( + "keeps GET and HEAD representation metadata aligned for $label", + async ({ path, statusCode }) => { + await withSandboxHost(async (origin) => { + const get = await fetch(`${origin}${path}`); + const head = await fetch(`${origin}${path}`, { method: "HEAD" }); + const getBody = await get.text(); + + expect(get.status).toBe(statusCode); + expect(head.status).toBe(statusCode); + expect(getBody).not.toBe(""); + expect(await head.text()).toBe(""); + expect(get.headers.get("content-length")).toBe(String(Buffer.byteLength(getBody))); + for (const header of [ + "content-type", + "content-length", + "cache-control", + "content-security-policy", + "permissions-policy", + "cross-origin-resource-policy", + "origin-agent-cluster", + "referrer-policy", + "x-content-type-options", + ]) { + expect(head.headers.get(header), header).toBe(get.headers.get(header)); + } + }); + }, + ); }); diff --git a/src/gateway/mcp-app-sandbox-http.ts b/src/gateway/mcp-app-sandbox-http.ts index d4672f874118..36b020d2c192 100644 --- a/src/gateway/mcp-app-sandbox-http.ts +++ b/src/gateway/mcp-app-sandbox-http.ts @@ -12,6 +12,7 @@ import { decodeSandboxHostCsp, SANDBOX_HOST_PATH, } from "../agents/sandbox-host.js"; +import { respondPlainText } from "./control-ui-http-utils.js"; const MCP_APP_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=(), clipboard-write=()"; @@ -20,13 +21,11 @@ function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerRespons try { url = new URL(req.url ?? "/", "http://localhost"); } catch { - res.statusCode = 400; - res.end("Bad Request"); + respondPlainText(res, 400, "Bad Request"); return; } if (url.pathname !== SANDBOX_HOST_PATH || (req.method !== "GET" && req.method !== "HEAD")) { - res.statusCode = 404; - res.end("Not Found"); + respondPlainText(res, 404, "Not Found"); return; } @@ -34,9 +33,7 @@ function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerRespons try { csp = decodeSandboxHostCsp(url.searchParams.get("csp")); } catch { - res.statusCode = 400; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("invalid MCP App sandbox policy"); + respondPlainText(res, 400, "invalid MCP App sandbox policy"); return; } @@ -49,7 +46,10 @@ function handleMcpAppSandboxHttpRequest(req: IncomingMessage, res: ServerRespons res.setHeader("Origin-Agent-Cluster", "?1"); res.setHeader("Referrer-Policy", "no-referrer"); res.setHeader("X-Content-Type-Options", "nosniff"); - res.end(req.method === "HEAD" ? undefined : buildSandboxHostProxyHtml(csp)); + const html = buildSandboxHostProxyHtml(csp); + // Keep GET and HEAD representation metadata aligned while suppressing the HEAD body. + res.setHeader("Content-Length", String(Buffer.byteLength(html))); + res.end(req.method === "HEAD" ? undefined : html); } /** Dedicated listener: this origin must never serve Control UI or authenticated Gateway data. */