Skip to content

Stored XSS in Metaflow cards via unsanitized Markdown component (svelte-markdown / marked sanitize:false, no DOMPurify) #3278

Description

@geo-chen

(https://bugcrowd.com/netflix returns 404 thus reporting here)

Summary

Metaflow renders run/task "cards" as HTML that is viewed in the Metaflow UI, by metaflow card view, and by the built-in card server (metaflow card server). The public card component Markdown (from metaflow.cards import Markdown) takes a text string and renders it through the bundled svelte-markdown / marked renderer. That renderer is configured with sanitize:false and the bundle contains no DOMPurify or any other sanitizer, so any raw HTML inside the markdown text is inserted into the page via element.innerHTML. When a flow places attacker-influenced text into a Markdown card component, that text executes as HTML and JavaScript in the browser of whoever views the card, in the card viewer's origin. This is a stored cross-site scripting vulnerability.

Details

Sink (Python side), metaflow/plugins/cards/card_modules/basic.py, MarkdownComponent.render puts the raw text into the card JSON under source with no escaping:

class MarkdownComponent(DefaultComponent):
    type = "markdown"
    def __init__(self, text=None):
        super().__init__(title=None, subtitle=None)
        self._text = text
    def render(self):
        datadict = super().render()
        _text = self._text
        if self._text is not None and not self._text.startswith("#"):
            _text = "\n" + _text
        datadict["source"] = _text      # raw, unescaped
        ...
        return datadict

The public component is metaflow/plugins/cards/card_modules/components.py:649 (class Markdown(UserComponent)), exported from metaflow/cards.py. The card JSON (containing {"type":"markdown","source": <attacker text>}) is base64-embedded into the card HTML by DefaultCard.render / BlankCard.render (basic.py) and decoded and rendered client-side by the bundled Svelte app metaflow/plugins/cards/card_modules/main.js.

Sink (client side), in the shipped bundle main.js:

  1. The component dispatch map routes markdown to the svelte-markdown component:
    {artifacts:..., markdown:q6, text:CI, valueBox:MI, ...}. Of all component types, only markdown reaches a raw-HTML path; the others bind text and are escaped by Svelte.
  2. The markdown component feeds componentData.source into svelte-markdown, which lexes it with marked. The marked default options in the bundle are:
    {... mangle:!0, pedantic:!1, renderer:null, sanitize:!1, sanitizer:null, ...} (sanitize:false).
  3. Raw HTML in the markdown becomes a marked html token. The bundle's html token renderer mounts the token text through Svelte's HtmlTag helper:
    class bz{ ... h(t){this.e.innerHTML=t; ...} ...} and the html renderer does t=new bz(!1); t.m(token.text, ...). This is a direct innerHTML = <attacker html> with no sanitization.

There is no DOMPurify (DOMPurify/dompurify/purify do not appear in the bundle), and sanitize/sanitizer are explicitly disabled. So <img src=x onerror=...>, <svg onload=...>, <iframe>, etc. in a Markdown card execute in the viewer.

Attacker and victim:

  • Attacker: any party who can influence a string that a flow routes into Markdown(...). Common real cases: a string Parameter supplied by another user when triggering the flow; the payload of an event/webhook that triggers a deployed flow; or data produced by an upstream step/task authored by a different user and rendered into a card.
  • Victim: anyone who opens the resulting card in the Metaflow UI, via metaflow card view, or via the card server (metaflow card server). The script runs in the viewer's origin (S:C); in an authenticated Metaflow UI it can read/exfiltrate the viewer's session and act as them.

PoC

A minimal flow that puts a trigger-time parameter value straight into a Markdown card (a very common pattern: showing the run's inputs in its card):

from metaflow import FlowSpec, step, card, current, Parameter
from metaflow.cards import Markdown

class ReportFlow(FlowSpec):
    note = Parameter("note", default="hello")

    @card(type="default")
    @step
    def start(self):
        current.card.append(Markdown("# Run note\n\n" + self.note))
        self.next(self.end)

    @step
    def end(self):
        pass

if __name__ == "__main__":
    ReportFlow()

Trigger with an attacker-controlled note:

python report_flow.py run --note '<img src=x onerror=alert(document.domain)>'

Then view the card (any viewer renders it):

python report_flow.py card view start
# or: metaflow card server   (then open the run in a browser)

The card stores the payload unescaped. The decoded card JSON contains:

{"type": "markdown", "source": "# Run note\n\n<img src=x onerror=alert(document.domain)>", ...}

When the card HTML is loaded, the bundled renderer inserts the <img> via innerHTML and its onerror runs alert(document.domain) in the viewer origin.

Validation performed: the card HTML produced by v2.19.34 was loaded in a DOM with the real shipped main.js. The renderer decoded the base64 task data, ran svelte-markdown, and inserted a live <img onerror=...> node into the DOM; firing its onerror executed and returned the viewer origin. Independently, the bundled marked configuration (sanitize:false) emits the raw <img src=x onerror=alert(document.domain)> HTML unchanged.

Impact

Stored cross-site scripting. An attacker who can influence text that a flow renders in a Markdown card executes arbitrary JavaScript in the browser of every user who views that card. In an authenticated Metaflow UI deployment this allows theft/abuse of the viewer's session (read private flows/cards, perform UI actions as the victim), so confidentiality and integrity impact in the viewer origin are high. The card component library is the correct fix location: markdown HTML should be sanitized (e.g. DOMPurify) before insertion, rather than relying on every flow author to pre-escape data they feed to Markdown.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions