GHSA-mxm6-v9r6-r94cHighCVSS 8.1

@nuxtjs/mdc's URL sanitizer misses SVG xlink:href and data:text/html, allowing XSS from untrusted markdown at the default configuration

Published
September 16, 2026
Last Modified
September 16, 2026

🔗 CVE IDs covered (1)

📋 Description

Summary

@nuxtjs/mdc renders untrusted markdown (including raw HTML) to a Vue component tree. Across two prior advisories it added a URL/attribute sanitizer to block dangerous links in that HTML: validateProps / validateProp and an unsafeLinkPrefix deny-list (dist/runtime/parser/utils/props.js). The sanitizer runs at parse time (dist/runtime/parser/compiler.js) and parseMarkdown enables raw HTML by default (allowDangerousHtml: true, dist/runtime/parser/options.js), so the sanitizer is the only barrier and it applies with no configuration required.

Two sibling vectors bypass that sanitizer at the default configuration:

  1. SVG anchor xlink:href. validateProp only scheme-checks attributes named exactly href or src:

    if (attribute === "href" || attribute === "src") return isAnchorLinkAllowed(value);
    return true;
    

    An xlink:href (parsed to the hast property xLinkHref) is neither, so a javascript: URL on an SVG <a> is passed through. The renderer maps the property back to the real attribute (MDCRenderer.vue: find(html, "xLinkHref").attribute is xlink:href), so the output element is <a xlink:href="javascript:...">. Clicking it runs the script in the page origin. Plain <a href="javascript:..."> is correctly stripped, which is what makes this the un-patched sibling.

  2. <iframe src="data:text/html,...">. data:text/html is present in unsafeLinkPrefix, but the check compares it against url.protocol:

    if (unsafeLinkPrefix.some((prefix) => url.protocol.toLowerCase().startsWith(prefix))) return false;
    

    For any data URI url.protocol is just "data:", so "data:".startsWith("data:text/html") is always false. Every data:text/* entry in the deny-list is therefore dead code, and <iframe src="data:text/html,<script>...</script>"> is allowed (iframe is not in the render-time dangerousTags, which is only ["script","base"]). The framed document executes script in an opaque origin. For contrast, srcdoc and object are blocked, so this is a precise gap rather than a general absence of filtering.

Reproduction

I will attach the zip file for POC, you can simply extract and run ./poc.sh to install mdc and show the poc in the html file. nuxtjs-mdc-xss_poc.zip

Two zero-argument checks:

  1. sh poc/poc.sh installs @nuxtjs/mdc and runs parseMarkdown (the documented API) at default. It shows the parsed tree retains a { xLinkHref: "javascript:..." } and iframe { src: "data:text/html,..." }, while the control payloads href="javascript:..." and srcdoc=... are removed by the sanitizer. This isolates the sanitizer bypass deterministically.
  2. poc/poc.sh also serves poc/poc.html over http (data: iframes and javascript: links are restricted under the file:// origin, so http is used). Open the printed URL and click the blue SVG link. The page contains the exact DOM the renderer produces for those parsed nodes; clicking the SVG link executes script in the page origin (same-origin), and the data:text/html iframe executes on load. The page prints VULNERABLE for each that fires.

Both vectors were confirmed executing in a current Chromium build: the SVG xlink:href link runs script in the document origin on click, and the data:text/html iframe runs script on load.

Suggested fix

In validateProp, scheme-check xlink:href (and the hast xLinkHref) the same way as href/src. In isAnchorLinkAllowed, compare the dangerous MIME-typed entries against the full URL (or href), not against url.protocol, so data:text/html is actually matched; or add iframe to the render-time dangerous-tag set / restrict iframe src schemes.

🎯 Affected products1

  • npm/@nuxtjs/mdc:< 0.22.1

🔗 References (6)