mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-11 20:19:51 +08:00
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:
@@ -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))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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}}", ""),
|
||||
)
|
||||
|
||||
@@ -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}}", ""),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user