feat(mcp-server): serve the bundled draw.io same-origin behind an api token

GET /drawio/<path> 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.
This commit is contained in:
dayuan.jiang
2026-10-11 20:56:07 +09:00
parent 38fe675d6b
commit d415f19cf5
7 changed files with 341 additions and 30 deletions
+171 -19
View File
@@ -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<number> {
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<string, string> = {
".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/<path>: 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
? `<span class="session">${sessionId.slice(-8)}</span>`
: "",
)
.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
? `<span class="session">${sessionId.slice(-8)}</span>`
: "",
)
.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))
)
}
+9 -1
View File
@@ -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()}`,
},
],
}
+4 -2
View File
@@ -40,7 +40,7 @@
</button>
</div>
</div>
<iframe id="drawio" src="{{DRAWIO_URL}}/?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
<iframe id="drawio" src="{{DRAWIO_URL}}?embed=1&proto=json&spin=1&libraries=1&noSaveBtn=1&noExitBtn=1&saveAndExit=0&themes=1&{{DRAWIO_PARAMS}}" title="draw.io editor"></iframe>
</div>
<div id="notice" role="status"></div>
<div id="history-modal">
@@ -85,7 +85,9 @@
</div>
<script>
const sessionId = {{SESSION_JSON}};
const DRAWIO_ORIGIN = {{ORIGIN_JSON}};
// Empty when draw.io is served from this origin
const DRAWIO_ORIGIN = {{ORIGIN_JSON}} || location.origin;
const API_TOKEN = {{TOKEN_JSON}};
</script>
<script>
{{SCRIPT}}
+8 -6
View File
@@ -1,4 +1,6 @@
const iframe = document.getElementById('drawio');
// Every API request carries the token the server wrote into this page
const api = (url, init = {}) => fetch(url, { ...init, headers: { ...(init.headers || {}), 'X-Drawio-Token': API_TOKEN } });
let currentVersion = 0, isReady = false, pendingXml = null, lastXml = null;
// The server state this tab is in step with (see stateId in http-server.ts);
// null until the first poll
@@ -82,7 +84,7 @@ window.addEventListener('message', (e) => {
// result: a poll answered before that still sees the
// request and would start the same export again.
const seq = msg.message.mcpExport;
fetch('/api/state', {
api('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, exportData: d, exportId: mcpExportId })
@@ -126,7 +128,7 @@ window.addEventListener('message', (e) => {
const t = thumbExport;
if (!t || msg.message.thumbExport !== t.n || latestXml !== t.xml) return;
thumbExport = null;
fetch('/api/history-svg', {
api('/api/history-svg', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, svg, stateId: t.stateId, version: t.version })
@@ -184,7 +186,7 @@ async function pushState(xml, svg = '', baseVersion = currentVersion, source = '
if (!sessionId) return;
pushesInFlight.push(xml);
try {
const r = await fetch('/api/state', {
const r = await api('/api/state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, xml, svg, baseVersion, source, stateId: sid })
@@ -282,7 +284,7 @@ async function poll() {
if (!sessionId) return;
const seq = ++pollSeq;
try {
const r = await fetch('/api/state?sessionId=' + encodeURIComponent(sessionId));
const r = await api('/api/state?sessionId=' + encodeURIComponent(sessionId));
if (!r.ok) return;
const s = await r.json();
// An older answer than one already handled (the interval, the 409
@@ -469,7 +471,7 @@ historyBtn.onclick = async () => {
// A list for a state the server recreated meanwhile has old ids
const sid = stateId;
try {
const r = await fetch('/api/history?sessionId=' + encodeURIComponent(sessionId));
const r = await api('/api/history?sessionId=' + encodeURIComponent(sessionId));
if (r.ok) {
const d = await r.json();
if (sid !== stateId) return;
@@ -529,7 +531,7 @@ restoreBtn.onclick = async () => {
restoreBtn.disabled = true;
restoreBtn.textContent = 'Restoring...';
try {
const r = await fetch('/api/restore', {
const r = await api('/api/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId, id: selectedId, stateId: historyStateId })
+147 -2
View File
@@ -6,19 +6,25 @@
* node:http so tests can set raw paths and Host/Origin headers.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import http from "node:http"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { afterAll, beforeAll, describe, expect, it } from "vitest"
import { installDomPolyfill } from "../src/dom.ts"
import { addHistory, getHistory } from "../src/history.ts"
import {
drawioEmbedParams,
getApiToken,
getState,
isSameOriginDrawio,
keepInHistory,
onSessionRecreate,
onStateChange,
requestExport,
requestSync,
restoreHistoryEntry,
setDrawioDir,
setState,
shutdown,
startHttpServer,
@@ -26,15 +32,26 @@ import {
} from "../src/http-server.ts"
let port = 0
// A stand-in for the bundled draw.io copy (dist/drawio), with a server-side
// part of the war that must never be served
let drawioDir = ""
beforeAll(async () => {
// XML parsing, as the server installs it at startup
installDomPolyfill()
drawioDir = mkdtempSync(join(tmpdir(), "drawio-static-"))
mkdirSync(join(drawioDir, "js"))
mkdirSync(join(drawioDir, "WEB-INF"))
writeFileSync(join(drawioDir, "index.html"), "<html>draw.io</html>")
writeFileSync(join(drawioDir, "js/app.min.js"), "// app")
writeFileSync(join(drawioDir, "WEB-INF/web.xml"), "<web-app/>")
setDrawioDir(drawioDir)
port = await startHttpServer(40000 + Math.floor(Math.random() * 10000))
})
afterAll(() => {
shutdown()
rmSync(drawioDir, { recursive: true, force: true })
})
interface Response {
@@ -43,7 +60,10 @@ interface Response {
body: string
}
/** Send a request; `body` may be split into several writes. */
/**
* Send a request as the preview page does (same Host, the API token);
* `body` may be split into several writes.
*/
function request(
path: string,
opts: {
@@ -59,7 +79,11 @@ function request(
port,
path,
method: opts.method ?? "GET",
headers: { host: `localhost:${port}`, ...opts.headers },
headers: {
host: `localhost:${port}`,
"x-drawio-token": getApiToken(),
...opts.headers,
},
},
(res) => {
const chunks: Buffer[] = []
@@ -463,8 +487,128 @@ describe("preview page", () => {
.map((m) => m[1])
.join("\n")
expect(scripts).toContain('const sessionId = "mcp-test-script";')
expect(scripts).toContain(`const API_TOKEN = "${getApiToken()}";`)
expect(() => new Function(scripts)).not.toThrow()
})
it("embeds the bundled draw.io from its own origin, else the external one", async () => {
expect(isSameOriginDrawio()).toBe(true)
const res = await request("/?mcp=mcp-test-origin")
expect(res.body).toContain('src="/drawio/index.html?embed=1&')
// The page takes its own origin for draw.io's messages
expect(res.body).toContain(
'const DRAWIO_ORIGIN = "" || location.origin;',
)
setDrawioDir(null)
try {
expect(isSameOriginDrawio()).toBe(false)
const external = await request("/?mcp=mcp-test-origin")
expect(external.body).toContain(
'src="https://embed.diagrams.net/?embed=1&',
)
expect(external.body).toContain(
'const DRAWIO_ORIGIN = "https://embed.diagrams.net" || location.origin;',
)
} finally {
setDrawioDir(drawioDir)
}
})
it("sends the security headers with the page", async () => {
const res = await request("/?mcp=mcp-test-headers")
expect(res.headers["content-security-policy"]).toBe(
"frame-ancestors 'self'",
)
expect(res.headers["x-content-type-options"]).toBe("nosniff")
})
})
describe("API token", () => {
it("refuses an API request without the token, or with another one", async () => {
setState("mcp-token", "<mxfile>kept</mxfile>")
for (const headers of [
{ "x-drawio-token": "" },
{ "x-drawio-token": "0123456789abcdef0123456789abcdef" },
]) {
const get = await request("/api/state?sessionId=mcp-token", {
headers,
})
expect(get.status).toBe(403)
const post = await postJson(
"/api/state",
{ sessionId: "mcp-token", xml: "<mxfile>replaced</mxfile>" },
headers,
)
expect(post.status).toBe(403)
const history = await request("/api/history?sessionId=mcp-token", {
headers,
})
expect(history.status).toBe(403)
}
expect(getState("mcp-token")?.xml).toBe("<mxfile>kept</mxfile>")
})
it("is not needed for the page and the draw.io files", async () => {
const headers = { "x-drawio-token": "" }
expect((await request("/?mcp=mcp-token", { headers })).status).toBe(200)
expect((await request("/drawio/index.html", { headers })).status).toBe(
200,
)
})
})
describe("bundled draw.io files", () => {
it("serves a file with its MIME type, cache and nosniff headers", async () => {
const res = await request("/drawio/js/app.min.js")
expect(res.status).toBe(200)
expect(res.body).toBe("// app")
expect(res.headers["content-type"]).toBe(
"text/javascript; charset=utf-8",
)
expect(res.headers["cache-control"]).toBe("public, max-age=86400")
expect(res.headers["x-content-type-options"]).toBe("nosniff")
expect(res.headers["content-security-policy"]).toBeUndefined()
})
it("frames the editor page only from this origin", async () => {
const res = await request("/drawio/index.html")
expect(res.status).toBe(200)
expect(res.headers["content-type"]).toBe("text/html; charset=utf-8")
expect(res.headers["content-security-policy"]).toBe(
"frame-ancestors 'self'",
)
})
it("never serves the war's server-side parts or files outside the copy", async () => {
for (const path of [
"/drawio/WEB-INF/web.xml",
"/drawio/web-inf/web.xml",
"/drawio/js/../WEB-INF/web.xml",
"/drawio/META-INF/MANIFEST.MF",
"/drawio/../package.json",
"/drawio/%2e%2e/package.json",
"/drawio/js/%2e%2e/%2e%2e/package.json",
"/drawio/js/..%5c..%5cpackage.json",
"/drawio/",
"/drawio/js",
"/drawio/missing.js",
]) {
const res = await request(path)
expect([403, 404], path).toContain(res.status)
expect(res.body).not.toContain("web-app")
expect(res.body).not.toContain('"name"')
}
})
it("answers 404 when no copy is bundled", async () => {
setDrawioDir(null)
try {
expect((await request("/drawio/index.html")).status).toBe(404)
} finally {
setDrawioDir(drawioDir)
}
})
})
describe("draw.io embed parameters from the host config", () => {
@@ -806,6 +950,7 @@ describe("bodies over the size limit", () => {
method: "POST",
headers: {
host: `localhost:${port}`,
"x-drawio-token": getApiToken(),
"content-type": "application/json",
},
},
+1
View File
@@ -12,6 +12,7 @@ const html = readFileSync(join(dir, "index.html"), "utf8")
.replace("{{DRAWIO_URL}}", "about:blank")
.replace("{{SESSION_JSON}}", '""')
.replace("{{ORIGIN_JSON}}", '"https://embed.diagrams.net"')
.replace("{{TOKEN_JSON}}", '"test-token"')
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
m[1].replace("{{SCRIPT}}", ""),
)
+1
View File
@@ -14,6 +14,7 @@ const html = readFileSync(join(dir, "index.html"), "utf8")
.replace("{{DRAWIO_URL}}", "about:blank")
.replace("{{SESSION_JSON}}", '"mcp-test"')
.replace("{{ORIGIN_JSON}}", JSON.stringify(DRAWIO))
.replace("{{TOKEN_JSON}}", JSON.stringify("test-token"))
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) =>
m[1].replace("{{SCRIPT}}", ""),
)