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 })