(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:
- 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.
- 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).
- 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.
(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 componentMarkdown(from metaflow.cards import Markdown) takes a text string and renders it through the bundled svelte-markdown / marked renderer. That renderer is configured withsanitize:falseand the bundle contains no DOMPurify or any other sanitizer, so any raw HTML inside the markdown text is inserted into the page viaelement.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.renderputs the raw text into the card JSON undersourcewith no escaping:The public component is
metaflow/plugins/cards/card_modules/components.py:649(class Markdown(UserComponent)), exported frommetaflow/cards.py. The card JSON (containing{"type":"markdown","source": <attacker text>}) is base64-embedded into the card HTML byDefaultCard.render/BlankCard.render(basic.py) and decoded and rendered client-side by the bundled Svelte appmetaflow/plugins/cards/card_modules/main.js.Sink (client side), in the shipped bundle
main.js:markdownto the svelte-markdown component:{artifacts:..., markdown:q6, text:CI, valueBox:MI, ...}. Of all component types, onlymarkdownreaches a raw-HTML path; the others bind text and are escaped by Svelte.componentData.sourceinto svelte-markdown, which lexes it withmarked. The marked default options in the bundle are:{... mangle:!0, pedantic:!1, renderer:null, sanitize:!1, sanitizer:null, ...}(sanitize:false).htmltoken. The bundle'shtmltoken renderer mounts the token text through Svelte'sHtmlTaghelper:class bz{ ... h(t){this.e.innerHTML=t; ...} ...}and the html renderer doest=new bz(!1); t.m(token.text, ...). This is a directinnerHTML = <attacker html>with no sanitization.There is no DOMPurify (
DOMPurify/dompurify/purifydo not appear in the bundle), andsanitize/sanitizerare explicitly disabled. So<img src=x onerror=...>,<svg onload=...>,<iframe>, etc. in a Markdown card execute in the viewer.Attacker and victim:
Markdown(...). Common real cases: a stringParametersupplied 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.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):
Trigger with an attacker-controlled note:
Then view the card (any viewer renders it):
The card stores the payload unescaped. The decoded card JSON contains:
When the card HTML is loaded, the bundled renderer inserts the
<img>viainnerHTMLand itsonerrorrunsalert(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 itsonerrorexecuted 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
Markdowncard 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 toMarkdown.