| author | |
| committer | |
| log | ac2cc78d4a4aa9508acb65ad5e9dab319dd1962a |
| tree | 66f23d78bcf04131dadd226647455e620a2e6e2d |
| parent | 8de61d4a97635382ee08d098b2a588e88532cc0a |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; |
| 18 | 18 | ||
| 19 | import { | 19 | import { |
| 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"; |
| 7 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; | 7 | import { isDevProxiedPath } from "@t3tools/shared/devProxy"; |
| 8 | import { injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp"; | 8 | import { injectMcpAppCookieBootstrap, injectMcpAppThemeBootstrap } from "@t3tools/shared/mcpApp"; |
| 9 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; | 9 | import { decodeOtlpTraceRecords } from "@t3tools/shared/observability"; |
| 10 | import * as Data from "effect/Data"; | 10 | import * as Data from "effect/Data"; |
| 11 | import * as Effect from "effect/Effect"; | 11 | import * 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"; |
| 4 | import { | 4 | import { |
| 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 | |||
| 214 | describe("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 | } |
| 251 | 251 | ||
| 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 | */ | ||
| 257 | export 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. */ |
| 253 | export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { | 269 | export function mcpAppAllowAttribute(permissions: McpAppPermissions | undefined): string { |
| 254 | const features: Array<string> = []; | 270 | const features: Array<string> = []; |