From d415f19cf5ae79378f401edf1dc1c475bf7888c1 Mon Sep 17 00:00:00 2001 From: "dayuan.jiang" Date: Sun, 11 Oct 2026 11:17:32 +0900 Subject: [PATCH] feat(mcp-server): serve the bundled draw.io same-origin behind an api token GET /drawio/ serves dist/drawio with a MIME table, a day of caching and nosniff; paths are normalized and never reach WEB-INF or META-INF. The preview embeds /drawio/index.html when the copy exists and DRAWIO_BASE_URL is unset, else the external draw.io as before (and start_session says so). Every /api request must carry the per-process X-Drawio-Token the page gets in its HTML; pages send frame-ancestors 'self' and nosniff. --- packages/mcp-server/src/http-server.ts | 190 ++++++++++++++++-- packages/mcp-server/src/index.ts | 10 +- packages/mcp-server/src/preview/index.html | 6 +- packages/mcp-server/src/preview/preview.js | 14 +- packages/mcp-server/tests/http-server.test.ts | 149 +++++++++++++- tests/unit/mcp-preview-history.test.ts | 1 + tests/unit/mcp-preview-recovery.test.ts | 1 + 7 files changed, 341 insertions(+), 30 deletions(-) diff --git a/packages/mcp-server/src/http-server.ts b/packages/mcp-server/src/http-server.ts index ced602ba..f77e9d6c 100644 --- a/packages/mcp-server/src/http-server.ts +++ b/packages/mcp-server/src/http-server.ts @@ -3,10 +3,10 @@ * Serves draw.io embed with state sync and history UI */ -import { randomUUID } from "node:crypto" -import { readFileSync } from "node:fs" +import { randomBytes, randomUUID } from "node:crypto" +import { createReadStream, existsSync, readFileSync, statSync } from "node:fs" import http from "node:http" -import { dirname, join } from "node:path" +import { dirname, extname, join, posix, resolve, sep } from "node:path" import { fileURLToPath } from "node:url" const MAX_BODY_BYTES = 10 * 1024 * 1024 // 10 MiB @@ -56,9 +56,43 @@ import { import { log } from "./logger.ts" import { BLANK_MXFILE } from "./pages.ts" -// Configurable draw.io embed URL for private deployments -const DRAWIO_BASE_URL = +// Configurable draw.io embed URL for private deployments. Set, it replaces +// the bundled copy (see drawioDir below). +export const DRAWIO_BASE_URL = process.env.DRAWIO_BASE_URL || "https://embed.diagrams.net" +const EXTERNAL_DRAWIO = !!process.env.DRAWIO_BASE_URL + +// Every /api request must carry this token in the X-Drawio-Token header. +// The page reads it from its HTML. Another website cannot add a custom +// header without a CORS preflight, which this server never grants, so its +// requests stop here whatever Host and Origin they manage to send. +const API_TOKEN = randomBytes(16).toString("hex") + +export function getApiToken(): string { + return API_TOKEN +} + +// The trimmed draw.io copy that scripts/fetch-drawio.mjs puts in dist/drawio, +// next to the compiled server; run from src with tsx, it is one level up +const HERE = dirname(fileURLToPath(import.meta.url)) +let drawioDir: string | null = + [join(HERE, "drawio"), join(HERE, "../dist/drawio")].find((dir) => + existsSync(join(dir, "index.html")), + ) ?? null + +/** For tests: serve draw.io from this directory (null: no bundled copy) */ +export function setDrawioDir(dir: string | null): void { + drawioDir = dir +} + +/** + * Whether the preview embeds draw.io from its own origin (the bundled copy), + * which lets the page call the editor directly. False with DRAWIO_BASE_URL, + * or when the bundled copy is missing. + */ +export function isSameOriginDrawio(): boolean { + return !EXTERNAL_DRAWIO && drawioDir !== null +} // Extract origin (scheme + host + port) from URL for postMessage security check function getOrigin(url: string): string { @@ -335,6 +369,11 @@ export function startHttpServer(port = 6002): Promise { server.listen(port, "127.0.0.1", () => { serverPort = port log.info(`HTTP server running on http://localhost:${port}`) + if (!EXTERNAL_DRAWIO && drawioDir === null) { + log.warn( + "No bundled draw.io (dist/drawio missing); the preview loads it from embed.diagrams.net", + ) + } resolve(port) }) }) @@ -425,7 +464,10 @@ function routeRequest( if (requestOrigin === `http://localhost:${serverPort}`) { res.setHeader("Access-Control-Allow-Origin", requestOrigin) res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - res.setHeader("Access-Control-Allow-Headers", "Content-Type") + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, X-Drawio-Token", + ) } if (req.method === "OPTIONS") { @@ -434,6 +476,20 @@ function routeRequest( return } + if (url.pathname.startsWith("/drawio/")) { + serveDrawioFile(req, res, url.pathname.slice("/drawio/".length)) + return + } + + if ( + url.pathname.startsWith("/api/") && + req.headers["x-drawio-token"] !== API_TOKEN + ) { + res.writeHead(403) + res.end("Forbidden") + return + } + if (url.pathname === "/" || url.pathname === "/index.html") { const sessionId = url.searchParams.get("mcp") || "" if (sessionId && !isValidSessionId(sessionId)) { @@ -456,7 +512,10 @@ function routeRequest( ensureSessionStateInitialized(sessionId) - res.writeHead(200, { "Content-Type": "text/html" }) + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + ...HTML_SECURITY_HEADERS, + }) res.end(getHtmlPage(sessionId)) } else if (url.pathname === "/api/state") { handleStateApi(req, res, url) @@ -795,8 +854,95 @@ function handleHistorySvgApi( }) } +// Pages may be framed only by this origin, and nothing is content-sniffed +const HTML_SECURITY_HEADERS = { + "Content-Security-Policy": "frame-ancestors 'self'", + "X-Content-Type-Options": "nosniff", +} + +const MIME_TYPES: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".gif": "image/gif", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".txt": "text/plain; charset=utf-8", + ".xml": "text/xml; charset=utf-8", + ".json": "application/json", + ".ico": "image/x-icon", + ".wasm": "application/wasm", +} + +/** GET /drawio/: a file of the bundled draw.io copy */ +function serveDrawioFile( + req: http.IncomingMessage, + res: http.ServerResponse, + rawPath: string, +): void { + if (req.method !== "GET") { + res.writeHead(405) + res.end("Method Not Allowed") + return + } + let rel: string + try { + rel = decodeURIComponent(rawPath) + } catch { + res.writeHead(400) + res.end("Bad Request") + return + } + // One normalized path inside the directory; the war's server-side parts + // are never served, whatever was extracted + const normalized = posix.normalize(rel) + const file = drawioDir ? resolve(drawioDir, normalized) : null + if ( + !drawioDir || + !file || + /[\\\0]/.test(rel) || + /(^|\/)(WEB-INF|META-INF)(\/|$)/i.test(normalized) || + !file.startsWith(drawioDir + sep) + ) { + res.writeHead(404) + res.end("Not Found") + return + } + let size: number + try { + const stat = statSync(file) + if (!stat.isFile()) throw new Error("not a file") + size = stat.size + } catch { + res.writeHead(404) + res.end("Not Found") + return + } + const ext = extname(file).toLowerCase() + res.writeHead(200, { + "Content-Type": MIME_TYPES[ext] || "application/octet-stream", + "Content-Length": size, + "Cache-Control": "public, max-age=86400", + "X-Content-Type-Options": "nosniff", + ...(ext === ".html" ? HTML_SECURITY_HEADERS : {}), + }) + createReadStream(file).pipe(res) +} + +/** Where the iframe loads the editor from (without its query) */ +function drawioEditorUrl(): string { + return isSameOriginDrawio() + ? "/drawio/index.html" + : `${normalizeUrl(DRAWIO_BASE_URL)}/` +} + // The preview page lives in src/preview (the build copies it to dist/preview) -const PREVIEW_DIR = join(dirname(fileURLToPath(import.meta.url)), "preview") +const PREVIEW_DIR = join(HERE, "preview") let previewTemplate: string | null = null function loadPreviewTemplate(): string { @@ -842,15 +988,21 @@ export function drawioEmbedParams( } function getHtmlPage(sessionId: string): string { - return loadPreviewTemplate() - .replace("{{SESSION_BADGE}}", () => - sessionId - ? `${sessionId.slice(-8)}` - : "", - ) - .replaceAll("{{DISABLED}}", sessionId ? "" : "disabled") - .replace("{{DRAWIO_URL}}", () => normalizeUrl(DRAWIO_BASE_URL)) - .replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams()) - .replace("{{SESSION_JSON}}", () => scriptJson(sessionId)) - .replace("{{ORIGIN_JSON}}", () => scriptJson(DRAWIO_ORIGIN)) + return ( + loadPreviewTemplate() + .replace("{{SESSION_BADGE}}", () => + sessionId + ? `${sessionId.slice(-8)}` + : "", + ) + .replaceAll("{{DISABLED}}", sessionId ? "" : "disabled") + .replace("{{DRAWIO_URL}}", () => drawioEditorUrl()) + .replace("{{DRAWIO_PARAMS}}", () => drawioEmbedParams()) + .replace("{{SESSION_JSON}}", () => scriptJson(sessionId)) + // Same origin: the page fills in its own origin + .replace("{{ORIGIN_JSON}}", () => + scriptJson(isSameOriginDrawio() ? "" : DRAWIO_ORIGIN), + ) + .replace("{{TOKEN_JSON}}", () => scriptJson(API_TOKEN)) + ) } diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 905687ca..717a0753 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -48,10 +48,12 @@ import { otherVersions, } from "./history.ts" import { + DRAWIO_BASE_URL, type ExportFormat, type ExportOptions, getServerPort, getState, + isSameOriginDrawio, keepInHistory, onSessionRecreate, onStateChange, @@ -407,6 +409,12 @@ registerWriteTool( ? `\n\nAuto-save: after every change the diagram is saved to ${savePath}. To continue this diagram in a later conversation, call start_session with session_id=${sessionId}. list_saved_diagrams lists older diagrams.` : "" const rulesNote = `\n\nYour own drawing rules: write them in ${customInstructionsPath()} (Markdown, up to 5000 characters, read on every call).` + // The bundled draw.io is served from the preview's own origin; + // an external one (DRAWIO_BASE_URL, or a build without the + // copy) cannot be reached by the page's scripts + const originNote = isSameOriginDrawio() + ? "" + : `\n\nThe preview loads draw.io from ${DRAWIO_BASE_URL} (another origin): same-origin editor features are off.` log.info(`Started session ${sessionId}, browser at ${browserUrl}`) @@ -414,7 +422,7 @@ registerWriteTool( content: [ { type: "text", - text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${saveNote}${rulesNote}\n\n${guideText()}`, + text: `${intro}\n\nSession ID: ${sessionId}\nBrowser URL: ${browserUrl}\n\nThe browser will now show real-time diagram updates.${originNote}${saveNote}${rulesNote}\n\n${guideText()}`, }, ], } diff --git a/packages/mcp-server/src/preview/index.html b/packages/mcp-server/src/preview/index.html index 1d72b08a..e6b85732 100644 --- a/packages/mcp-server/src/preview/index.html +++ b/packages/mcp-server/src/preview/index.html @@ -40,7 +40,7 @@ - +
@@ -85,7 +85,9 @@