medium

CVE-2026-111059

PyPI · mercury

Summary

Mercury: Stored / DOM Cross-Site Scripting (XSS): mr.Markdown renders untrusted content as raw, unsanitized HTML

Severity
medium
CVSS
6.1
EPSS
0.3% (p21)
CWE
CWE-79, CWE-116
Also known as
GHSA-2658-85cv-9wwq
Published
2026-10-19
Updated
2026-10-19

Advisory details

Summary

Mercury turns Jupyter notebooks into shared web apps/dashboards. The mr.Markdown widget converts its input to HTML with python-markdown and no sanitization step. MarkdownWidget is an ipywidgets.HTML widget, whose value is written to the page as raw HTML (via innerHTML) with no sanitization, so the result reaches the DOM verbatim. When an app passes untrusted data (database rows, user submissions, uploaded file contents, or an external/LLM API response) to mr.Markdown, HTML/JS in that data executes in the browser of whoever views the app — a data-driven stored/DOM-based XSS.

This is a library-provided rendering sink that fails to neutralize output; it is triggered by ordinary, reasonable application code, not by an author mistake.

Root cause

mercury/md.py — MarkdownWidget._to_html() (verified at v3.2.6, lines ~57–65):

def _to_html(self, text: str) -> str:
    if md_lib is not None:
        body = md_lib.markdown(text)          # python-markdown: passes raw HTML through
    else:
        body = f"<pre>{escape(text)}</pre>"   # escape() is ONLY the md-lib-absent fallback
    return self._apply_inline_theme(body)     # regex styling — adds style=, removes nothing

Two neutralization failures compound:

  1. Markdown → HTML is unsanitized. python-markdown passes raw HTML through by default: <img src=x onerror=…>, <svg onload=…>, and javascript: links survive verbatim. There is no bleach/nh3 allowlist anywhere in the package. _apply_inline_theme() only injects inline style= attributes via regex; it is not a sanitizer and removes nothing. escape() runs only in the md-lib-absent fallback.

  2. The rendering sink is unsanitized innerHTML. MarkdownWidget (mercury/md.py:21) subclasses ipywidgets.HTML. The ipywidgets HTML widget renders its value into the DOM as raw HTML via innerHTML, with no sanitization (this is the widget's design). Event-handler payloads such as <img src=x onerror=…> therefore execute when the widget is displayed.

On the "sanitize" that exists: mercury_app/notebook_sanitize.py (sanitize_notebook_for_mercury_runtime) and packages/lab/src/mercury/save.ts (sanitizeMercuryNotebookContent) strip runtime metadata and clear code-cell saved outputs. They do not neutralize HTML/JS, and they do not touch widget values or markdown source. "Sanitize" here is notebook hygiene, not XSS defense.

Threat model (attacker → victim, not self-inflicted)

The CVE-relevant framing is data-driven: the app author writes ordinary code, and the data is attacker-controlled.

import mercury as mr
# `row` comes from a DB / user submission / uploaded file / external API —
# i.e. NOT authored by the app developer:
mr.Markdown(row["comment"])

The attacker supplies the untrusted value (posts the comment, uploads the file, poisons the API/RAG source). A different user — another app user, a reviewer, an admin — then opens the app, and the payload executes in their browser. The vulnerable component (Mercury's server-side widget rendering) and the impacted component (the victim's browser session in the app origin) differ, which is the classic stored-XSS attacker/victim separation.

Amplifier — session sharing (--keep-session). Mercury can serve one shared kernel session to all viewers (mercury run … --keep-session, README "Arguments": "share the same session between multiple users … others will see this"). It is opt-in and defaults to off (keepSession=False in mercury_app/handlers.py), so it is not required for the base case — any app that renders one user's untrusted data to another user is already vulnerable. When enabled, content one viewer submits is rendered into the session other viewers see, extending this to cross-user delivery. Authentication is opt-in and off by default: a free password gate exists (mercury run … --pass=…, README), while user-based authentication is a paid add-on. Because auth is off unless the operator enables it, unauthenticated public deployments — where the attacker needs no account — are a realistic default.

Proof of Concept

import mercury as mr

# Value that would realistically arrive from untrusted data (DB row, form field,
# uploaded file, API/LLM response):
untrusted = '<img src=x onerror=alert(String.fromCharCode(88,83,83))>'

mr.Markdown(untrusted)   # rendered to raw HTML, unsanitized, into the app DOM

The generated widget HTML contains the payload verbatim; when the widget is displayed, the onerror handler runs.

What was confirmed (and how)

Impact

Mercury shares notebooks as web apps/dashboards, often for non-technical audiences. When an app renders untrusted data through mr.Markdown, the generated page carries attacker-controlled script that runs in the browser of any other viewer, in the app origin — enabling session/cookie/token theft, UI spoofing and forced actions as the victim, and pivoting within the hosting origin. With --keep-session, this extends to cross-user delivery within a shared session.

Severity rationale (CVSS 3.1 per-metric)

Base score reflects the realistic default: an unauthenticated deployment whose app renders untrusted data via mr.Markdown.

Metric Value Reason
AV Attack Vector Network (N) App is served over HTTP to remote users.
AC Attack Complexity Low (L) No special conditions or race; supply data that reaches an mr.Markdown call.
PR Privileges Required None (N) Baseline: the injection channel (public form/upload/API) needs no account; auth is optional/paid. See variant below.
UI User Interaction Required (R) A victim must open/view the app page for the stored payload to run.
S Scope Changed (C) The vulnerable component (server-side widget render) injects script that executes in a different security authority — the victim's browser session/origin. (This is the canonical XSS scoping in the CVSS 3.1 spec.)
C Confidentiality Low (L) Script can read data/session material available to the app origin; scored L (not H) for a generic app without asserting full account takeover.
I Integrity Low (L) Script can alter the page and act as the victim within the app; scored L generically.
A Availability None (N) No direct availability impact.

Result: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N = 6.1 (Medium).

Deployment-dependent variants (state your environment's when filing):

References

Related advisories

Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.

Check my repo

Summarize with AI

ChatGPTClaudePerplexity

Sources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.