authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 11:42:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 18:42:58+00:00
logac2cc78d4a4aa9508acb65ad5e9dab319dd1962a
tree66f23d78bcf04131dadd226647455e620a2e6e2d
parent8de61d4a97635382ee08d098b2a588e88532cc0a
signature Signed by PGP key B5690EEEBB952194

fix(client): tolerate blocked cookies in MCP app sandboxes (#17027)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

4 files changed, 79 insertions(+), 3 deletions(-)

apps/server/src/http.test.ts+2-1
......@@ -18,6 +18,7 @@ import { openMediaFile } from "./assets/MediaFile.ts";
1818
1919import {
2020 injectMcpAppCsp,
21 injectMcpAppCookieBootstrap,
2122 injectMcpAppThemeBootstrap,
2223 MCP_APP_MAX_HTML_BYTES,
2324} from "@t3tools/shared/mcpApp";
......@@ -58,7 +59,7 @@ describe("MCP app asset responses", () => {
5859 method,
5960 ),
6061 );
61 const expected = injectMcpAppThemeBootstrap(html);
62 const expected = injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(html));
6263 expect(response.status).toBe(200);
6364 expect(response.headers.get("content-length")).toBe(
6465 String(new TextEncoder().encode(expected).byteLength),
apps/server/src/http.ts+2-2
......@@ -5,7 +5,7 @@ import {
55 EnvironmentHttpApi,
66} from "@t3tools/contracts";
77import { isDevProxiedPath } from "@t3tools/shared/devProxy";
8import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
8import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
99import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
1010import * as Data from "effect/Data";
1111import * as Effect from "effect/Effect";
......@@ -185,7 +185,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
185185 return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });
186186 }
187187 const body = new TextEncoder().encode(
188 injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)),
188 injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes))),
189189 );
190190 headers["Content-Length"] = String(body.byteLength);
191191 return method === "HEAD"
packages/shared/src/mcpApp.test.ts+59
......@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
44import {
55 injectMcpAppCsp,
66 injectMcpAppThemeBootstrap,
7 injectMcpAppCookieBootstrap,
78 mcpAppContentSecurityPolicy,
89 mcpAppToolCallableByApp,
910 readMcpAppCsp,
......@@ -209,3 +210,61 @@ describe("mcpAppFromToolItem", () => {
209210 expect(output?.t3McpApp?.server).toBe("weather");
210211 });
211212});
213
214describe("MCP App cookie bootstrap", () => {
215 const vendor = `<script>document.cookie.split(";");document.cookie="analytics=1";document.root="rendered";</script>`;
216 const execute = (html: string, document: object) => {
217 for (const script of html.matchAll(/<script>([\s\S]*?)<\/script>/g)) {
218 NodeVM.runInNewContext(script[1]!, { document });
219 }
220 };
221
222 it("lets cookie-reading startup code render in an opaque document without granting storage", () => {
223 const blocked = new DOMException("Opaque origin", "SecurityError");
224 const prototype = Object.defineProperty({}, "cookie", {
225 get() {
226 throw blocked;
227 },
228 set() {
229 throw blocked;
230 },
231 });
232 const document = Object.assign(
233 Object.create(prototype) as { cookie: string; root: string; indexedDB: unknown },
234 { root: "" },
235 );
236 Object.defineProperty(document, "indexedDB", {
237 get() {
238 throw blocked;
239 },
240 });
241 const saved = injectMcpAppCsp(vendor, { connectDomains: ["https://analytics.example"] });
242 const html = injectMcpAppCookieBootstrap(saved);
243 expect(html.indexOf("Content-Security-Policy")).toBeLessThan(html.indexOf("<script>"));
244 expect(html).toContain("connect-src https://analytics.example");
245 execute(html, document);
246 expect(document.root).toBe("rendered");
247 expect(document.cookie).toBe("");
248 document.cookie = "session=secret";
249 expect(document.cookie).toBe("");
250 expect(() => document.indexedDB).toThrow(blocked);
251 expect(() => Reflect.get(prototype, "cookie")).toThrow(blocked);
252 });
253
254 it("leaves functioning cookie reads and writes unchanged", () => {
255 const document = { cookie: "existing=1", root: "" };
256 execute(injectMcpAppCookieBootstrap(vendor), document);
257 expect(document).toEqual({ cookie: "analytics=1", root: "rendered" });
258 });
259
260 it("leaves other cookie failures unchanged", () => {
261 const failure = new TypeError("Unexpected cookie failure");
262 const document = Object.defineProperty({}, "cookie", {
263 get() {
264 throw failure;
265 },
266 });
267 expect(() => execute(injectMcpAppCookieBootstrap(vendor), document)).toThrow(failure);
268 expect(() => Reflect.get(document, "cookie")).toThrow(failure);
269 });
270});
packages/shared/src/mcpApp.ts+16
......@@ -249,6 +249,22 @@ export function injectMcpAppThemeBootstrap(html: string): string {
249249 : `<!doctype html>${script}${html}`;
250250}
251251
252/**
253 * Analytics libraries often read cookies during startup. An opaque document
254 * has none, but browsers throw instead of returning an empty cookie string.
255 * Keep the sandbox and make that document-local case behave like no cookies.
256 */
257export function injectMcpAppCookieBootstrap(html: string): string {
258 const bootstrap = `<script>(function(){try{void document.cookie;}catch(error){if(error&&error.name==="SecurityError"){Object.defineProperty(document,"cookie",{configurable:true,get:function(){return "";},set:function(){}});}}})();</script>`;
259 // Saved captures begin with the CSP inserted by injectMcpAppCsp. Keep it
260 // ahead of executable code, including this bootstrap, without rewriting it.
261 const prefix =
262 /^(?:\uFEFF?\s*<!doctype[^>]*>\s*)?(?:<meta http-equiv="Content-Security-Policy" content="[^"]*">)?/i.exec(
263 html,
264 )![0];
265 return `${prefix}${bootstrap}${html.slice(prefix.length)}`;
266}
267
252268/** The iframe `allow` attribute for the permissions an app declared. */
253269export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
254270 const features: Array<string> = [];