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";...@@ -18,6 +18,7 @@ import { openMediaFile } from "./assets/MediaFile.ts";
1818
19import {19import {
20 injectMcpAppCsp,20 injectMcpAppCsp,
21 injectMcpAppCookieBootstrap,
21 injectMcpAppThemeBootstrap,22 injectMcpAppThemeBootstrap,
22 MCP_APP_MAX_HTML_BYTES,23 MCP_APP_MAX_HTML_BYTES,
23} from "@t3tools/shared/mcpApp";24} from "@t3tools/shared/mcpApp";
...@@ -58,7 +59,7 @@ describe("MCP app asset responses", () => {...@@ -58,7 +59,7 @@ describe("MCP app asset responses", () => {
58 method,59 method,
59 ),60 ),
60 );61 );
61 const expected = injectMcpAppThemeBootstrap(html);62 const expected = injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(html));
62 expect(response.status).toBe(200);63 expect(response.status).toBe(200);
63 expect(response.headers.get("content-length")).toBe(64 expect(response.headers.get("content-length")).toBe(
64 String(new TextEncoder().encode(expected).byteLength),65 String(new TextEncoder().encode(expected).byteLength),
apps/server/src/http.ts+2-2
...@@ -5,7 +5,7 @@ import {...@@ -5,7 +5,7 @@ import {
5 EnvironmentHttpApi,5 EnvironmentHttpApi,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { isDevProxiedPath } from "@t3tools/shared/devProxy";7import { isDevProxiedPath } from "@t3tools/shared/devProxy";
8import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";8import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp";
9import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";9import { decodeOtlpTraceRecords } from "@t3tools/shared/observability";
10import * as Data from "effect/Data";10import * as Data from "effect/Data";
11import * as Effect from "effect/Effect";11import * as Effect from "effect/Effect";
...@@ -185,7 +185,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (...@@ -185,7 +185,7 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* (
185 return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });185 return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 });
186 }186 }
187 const body = new TextEncoder().encode(187 const body = new TextEncoder().encode(
188 injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes)),188 injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes))),
189 );189 );
190 headers["Content-Length"] = String(body.byteLength);190 headers["Content-Length"] = String(body.byteLength);
191 return method === "HEAD"191 return method === "HEAD"
packages/shared/src/mcpApp.test.ts+59
...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,7 @@ import { describe, expect, it } from "vite-plus/test";
4import {4import {
5 injectMcpAppCsp,5 injectMcpAppCsp,
6 injectMcpAppThemeBootstrap,6 injectMcpAppThemeBootstrap,
7 injectMcpAppCookieBootstrap,
7 mcpAppContentSecurityPolicy,8 mcpAppContentSecurityPolicy,
8 mcpAppToolCallableByApp,9 mcpAppToolCallableByApp,
9 readMcpAppCsp,10 readMcpAppCsp,
...@@ -209,3 +210,61 @@ describe("mcpAppFromToolItem", () => {...@@ -209,3 +210,61 @@ describe("mcpAppFromToolItem", () => {
209 expect(output?.t3McpApp?.server).toBe("weather");210 expect(output?.t3McpApp?.server).toBe("weather");
210 });211 });
211});212});
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 {...@@ -249,6 +249,22 @@ export function injectMcpAppThemeBootstrap(html: string): string {
249 : `<!doctype html>${script}${html}`;249 : `<!doctype html>${script}${html}`;
250}250}
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
252/** The iframe `allow` attribute for the permissions an app declared. */268/** The iframe `allow` attribute for the permissions an app declared. */
253export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {269export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string {
254 const features: Array<string> = [];270 const features: Array<string> = [];