fix(gateway): preserve sandbox HEAD response metadata (#118553)

Punchcard-Session: coral-summit-orchard-93
This commit is contained in:
zengLingbiao
2026-08-10 21:54:57 +08:00
committed by GitHub
parent 3b3c540896
commit b121219a44
2 changed files with 74 additions and 8 deletions
+66
View File
@@ -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<void>): Promise<void> {
const server = createSandboxHostHttpServer();
await new Promise<void>((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<void>((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));
}
});
},
);
});
+8 -8
View File
@@ -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. */