GHSA-2658-85CV-9WWQ
Vulnerability from github – Published: 2026-10-19 17:01 – Updated: 2026-10-19 17:01Summary
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:
-
Markdown → HTML is unsanitized.
python-markdownpasses raw HTML through by default:<img src=x onerror=…>,<svg onload=…>, andjavascript:links survive verbatim. There is nobleach/nh3allowlist anywhere in the package._apply_inline_theme()only injects inlinestyle=attributes via regex; it is not a sanitizer and removes nothing.escape()runs only in the md-lib-absent fallback. -
The rendering sink is unsanitized
innerHTML.MarkdownWidget(mercury/md.py:21) subclassesipywidgets.HTML. The ipywidgets HTML widget renders itsvalueinto the DOM as raw HTML viainnerHTML, 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)
- Confirmed by execution in a real browser (sink level): driving the actual
python-markdownoutput into the sameipywidgets.HTMLvaluesink the widget uses causes the payload to execute in headless Chromium. Execution was captured via Playwright'sdialogevent (which fires only on a genuinealert(), so the mere presence of the string in the HTML cannot cause a false positive). A benign control (**hello**) produced zero dialogs, confirming the harness reports only real execution.python-markdownpasses<img src=x onerror=…>through verbatim by default (behavior is not version-specific). - Reasoned from source (not executed end-to-end): the full path from a
mr.Markdown(...)call in a served Mercury app to a viewer's DOM was not run end-to-end, because no isolated container/VM was available in the reporting environment. BecauseMarkdownWidgetis theipywidgets.HTMLsink above and Mercury's frontend applies no sanitizer to widget HTML values (the only frontend sanitizer strips notebook metadata at save time), the served-app path is expected to behave identically. Reproduce end-to-end before publishing if you want the advisory to state full-path browser confirmation.
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):
- Injection channel requires an authenticated low-privilege user → PR:L →
…/PR:L/UI:R/S:C/C:L/I:L/A:N = 5.4.
- App origin exposes high-value auth material such that XSS yields full
session/account takeover → C:H/I:H raises the score; justify per deployment
rather than claiming it universally.
Remediation
Primary — sanitize by default in mr.Markdown, with an explicit opt-out for the
deliberate raw-HTML use case (backward-compatible, non-breaking):
# mercury/md.py
def _to_html(self, text: str, unsafe_allow_html: bool = False) -> str:
if md_lib is not None:
body = md_lib.markdown(text)
else:
body = f"<pre>{escape(text)}</pre>"
if not unsafe_allow_html:
# allowlist tags/attributes; strip javascript:/data: schemes and event handlers
body = nh3.clean(body) # or bleach.clean(..., strip=True)
return self._apply_inline_theme(body)
Expose unsafe_allow_html=False on mr.Markdown(...) so authors who deliberately
embed trusted raw HTML can opt in, while the default is safe. This mirrors the
convention used by comparable tools (e.g. Streamlit's unsafe_allow_html).
Defense-in-depth: since widget HTML values are written via innerHTML without
sanitization, consider sanitizing widget HTML values at the frontend before they
reach the DOM, and/or serving a Content-Security-Policy that forbids inline event
handlers, so a single missed sink is not immediately exploitable.
Additional hardening (secondary — not the reported vulnerability)
Several widget ESMs write the label with innerHTML, e.g. mercury/text.py
topLabel.innerHTML = model.get("label") || … (same shape in number.py,
time.py, date/datetime/daterange/select/json/file, and
indicator.py's _card_html). This is not an attacker→victim XSS path: labels
are static strings set by the app author in Python; URL/url_params overrides
affect only widget value (assigned via the safe input.value property), never
label, so there is no end-user/URL route into a label. It is a defense-in-depth /
author footgun only. Recommended regardless: render UI strings with textContent,
or html.escape labels, so output encoding is applied uniformly.
{
"affected": [
{
"package": {
"ecosystem": "PyPI",
"name": "mercury"
},
"ranges": [
{
"events": [
{
"introduced": "3.0.0"
},
{
"fixed": "3.2.11"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-111059"
],
"database_specific": {
"cwe_ids": [
"CWE-116",
"CWE-79"
],
"github_reviewed": true,
"github_reviewed_at": "2026-10-19T17:01:20Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\nMercury turns Jupyter notebooks into shared web apps/dashboards. The `mr.Markdown`\nwidget converts its input to HTML with **python-markdown and no sanitization\nstep**. `MarkdownWidget` is an `ipywidgets.HTML` widget, whose value is written to\nthe page as **raw HTML (via `innerHTML`) with no sanitization**, so the result\nreaches the DOM verbatim. When an app passes **untrusted data** (database rows,\nuser submissions, uploaded file contents, or an external/LLM API response) to\n`mr.Markdown`, HTML/JS in that data executes in the browser of **whoever views the\napp** \u2014 a data-driven stored/DOM-based XSS.\n\nThis is a library-provided rendering sink that fails to neutralize output; it is\ntriggered by ordinary, reasonable application code, not by an author mistake.\n\n## Root cause\n\n`mercury/md.py` \u2014 `MarkdownWidget._to_html()` (verified at v3.2.6, lines ~57\u201365):\n\n```python\ndef _to_html(self, text: str) -\u003e str:\n if md_lib is not None:\n body = md_lib.markdown(text) # python-markdown: passes raw HTML through\n else:\n body = f\"\u003cpre\u003e{escape(text)}\u003c/pre\u003e\" # escape() is ONLY the md-lib-absent fallback\n return self._apply_inline_theme(body) # regex styling \u2014 adds style=, removes nothing\n```\n\nTwo neutralization failures compound:\n\n1. **Markdown \u2192 HTML is unsanitized.** `python-markdown` passes raw HTML through by\n default: `\u003cimg src=x onerror=\u2026\u003e`, `\u003csvg onload=\u2026\u003e`, and `javascript:` links\n survive verbatim. There is no `bleach`/`nh3` allowlist anywhere in the package.\n `_apply_inline_theme()` only injects inline `style=` attributes via regex; it is\n not a sanitizer and removes nothing. `escape()` runs only in the\n md-lib-absent fallback.\n\n2. **The rendering sink is unsanitized `innerHTML`.** `MarkdownWidget`\n (`mercury/md.py:21`) subclasses `ipywidgets.HTML`. The ipywidgets HTML widget\n renders its `value` into the DOM as raw HTML via `innerHTML`, with no\n sanitization (this is the widget\u0027s design). Event-handler payloads such as\n `\u003cimg src=x onerror=\u2026\u003e` therefore execute when the widget is displayed.\n\n**On the \"sanitize\" that exists:** `mercury_app/notebook_sanitize.py`\n(`sanitize_notebook_for_mercury_runtime`) and `packages/lab/src/mercury/save.ts`\n(`sanitizeMercuryNotebookContent`) strip *runtime metadata* and clear *code-cell\nsaved outputs*. They do **not** neutralize HTML/JS, and they do not touch widget\nvalues or markdown source. \"Sanitize\" here is notebook hygiene, not XSS defense.\n\n## Threat model (attacker \u2192 victim, not self-inflicted)\n\nThe CVE-relevant framing is **data-driven**: the app author writes ordinary code,\nand the *data* is attacker-controlled.\n\n```python\nimport mercury as mr\n# `row` comes from a DB / user submission / uploaded file / external API \u2014\n# i.e. NOT authored by the app developer:\nmr.Markdown(row[\"comment\"])\n```\n\nThe attacker supplies the untrusted value (posts the comment, uploads the file,\npoisons the API/RAG source). A **different** user \u2014 another app user, a reviewer,\nan admin \u2014 then opens the app, and the payload executes in *their* browser. The\nvulnerable component (Mercury\u0027s server-side widget rendering) and the impacted\ncomponent (the victim\u0027s browser session in the app origin) differ, which is the\nclassic stored-XSS attacker/victim separation.\n\n**Amplifier \u2014 session sharing (`--keep-session`).** Mercury can serve one shared\nkernel session to all viewers (`mercury run \u2026 --keep-session`, README \"Arguments\":\n\"share the same session between multiple users \u2026 others will see this\"). It is\n**opt-in and defaults to off** (`keepSession=False` in `mercury_app/handlers.py`),\nso it is *not required* for the base case \u2014 any app that renders one user\u0027s\nuntrusted data to another user is already vulnerable. When enabled, content one\nviewer submits is rendered into the session other viewers see, extending this to\ncross-user delivery. Authentication is **opt-in and off by default**: a free\npassword gate exists (`mercury run \u2026 --pass=\u2026`, README), while user-based\nauthentication is a paid add-on. Because auth is off unless the operator enables\nit, unauthenticated public deployments \u2014 where the attacker needs no account \u2014 are\na realistic default.\n\n## Proof of Concept\n\n```python\nimport mercury as mr\n\n# Value that would realistically arrive from untrusted data (DB row, form field,\n# uploaded file, API/LLM response):\nuntrusted = \u0027\u003cimg src=x onerror=alert(String.fromCharCode(88,83,83))\u003e\u0027\n\nmr.Markdown(untrusted) # rendered to raw HTML, unsanitized, into the app DOM\n```\n\nThe generated widget HTML contains the payload verbatim; when the widget is\ndisplayed, the `onerror` handler runs.\n\n### What was confirmed (and how)\n\n- **Confirmed by execution in a real browser (sink level):** driving the *actual\n `python-markdown` output* into the same `ipywidgets.HTML` `value` sink the widget\n uses causes the payload to execute in headless Chromium. Execution was captured\n via Playwright\u0027s `dialog` event (which fires only on a genuine `alert()`, so the\n mere presence of the string in the HTML cannot cause a false positive). A benign\n control (`**hello**`) produced **zero** dialogs, confirming the harness reports\n only real execution. `python-markdown` passes `\u003cimg src=x onerror=\u2026\u003e` through\n verbatim by default (behavior is not version-specific).\n- **Reasoned from source (not executed end-to-end):** the full path from a\n `mr.Markdown(...)` call in a *served Mercury app* to a viewer\u0027s DOM was not run\n end-to-end, because no isolated container/VM was available in the reporting\n environment. Because `MarkdownWidget` *is* the `ipywidgets.HTML` sink above and\n Mercury\u0027s frontend applies no sanitizer to widget HTML values (the only frontend\n sanitizer strips notebook metadata at save time), the served-app path is expected\n to behave identically. Reproduce end-to-end before publishing if you want the\n advisory to state full-path browser confirmation.\n\n## Impact\n\nMercury shares notebooks as web apps/dashboards, often for non-technical audiences.\nWhen an app renders untrusted data through `mr.Markdown`, the generated page carries\nattacker-controlled script that runs in the browser of any other viewer, in the\napp origin \u2014 enabling session/cookie/token theft, UI spoofing and forced actions as\nthe victim, and pivoting within the hosting origin. With `--keep-session`, this\nextends to cross-user delivery within a shared session.\n\n## Severity rationale (CVSS 3.1 per-metric)\n\nBase score reflects the realistic default: an unauthenticated deployment whose app\nrenders untrusted data via `mr.Markdown`.\n\n| Metric | Value | Reason |\n|---|---|---|\n| **AV** Attack Vector | **Network (N)** | App is served over HTTP to remote users. |\n| **AC** Attack Complexity | **Low (L)** | No special conditions or race; supply data that reaches an `mr.Markdown` call. |\n| **PR** Privileges Required | **None (N)** | Baseline: the injection channel (public form/upload/API) needs no account; auth is optional/paid. See variant below. |\n| **UI** User Interaction | **Required (R)** | A victim must open/view the app page for the stored payload to run. |\n| **S** Scope | **Changed (C)** | The vulnerable component (server-side widget render) injects script that executes in a different security authority \u2014 the victim\u0027s browser session/origin. (This is the canonical XSS scoping in the CVSS 3.1 spec.) |\n| **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. |\n| **I** Integrity | **Low (L)** | Script can alter the page and act as the victim within the app; scored L generically. |\n| **A** Availability | **None (N)** | No direct availability impact. |\n\nResult: **`CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N` = 6.1 (Medium)**.\n\nDeployment-dependent variants (state your environment\u0027s when filing):\n- Injection channel requires an authenticated low-privilege user \u2192 **PR:L** \u2192\n `\u2026/PR:L/UI:R/S:C/C:L/I:L/A:N` = **5.4**.\n- App origin exposes high-value auth material such that XSS yields full\n session/account takeover \u2192 **C:H/I:H** raises the score; justify per deployment\n rather than claiming it universally.\n\n## Remediation\n\nPrimary \u2014 **sanitize by default in `mr.Markdown`, with an explicit opt-out for the\ndeliberate raw-HTML use case** (backward-compatible, non-breaking):\n\n```python\n# mercury/md.py\ndef _to_html(self, text: str, unsafe_allow_html: bool = False) -\u003e str:\n if md_lib is not None:\n body = md_lib.markdown(text)\n else:\n body = f\"\u003cpre\u003e{escape(text)}\u003c/pre\u003e\"\n if not unsafe_allow_html:\n # allowlist tags/attributes; strip javascript:/data: schemes and event handlers\n body = nh3.clean(body) # or bleach.clean(..., strip=True)\n return self._apply_inline_theme(body)\n```\n\nExpose `unsafe_allow_html=False` on `mr.Markdown(...)` so authors who deliberately\nembed trusted raw HTML can opt in, while the default is safe. This mirrors the\nconvention used by comparable tools (e.g. Streamlit\u0027s `unsafe_allow_html`).\n\nDefense-in-depth: since widget HTML values are written via `innerHTML` without\nsanitization, consider sanitizing widget HTML values at the frontend before they\nreach the DOM, and/or serving a Content-Security-Policy that forbids inline event\nhandlers, so a single missed sink is not immediately exploitable.\n\n## Additional hardening (secondary \u2014 not the reported vulnerability)\n\nSeveral widget ESMs write the **label** with `innerHTML`, e.g. `mercury/text.py`\n`topLabel.innerHTML = model.get(\"label\") || \u2026` (same shape in `number.py`,\n`time.py`, `date`/`datetime`/`daterange`/`select`/`json`/`file`, and\n`indicator.py`\u0027s `_card_html`). **This is not an attacker\u2192victim XSS path**: labels\nare static strings set by the app author in Python; URL/`url_params` overrides\naffect only widget `value` (assigned via the safe `input.value` property), never\n`label`, so there is no end-user/URL route into a label. It is a defense-in-depth /\nauthor footgun only. Recommended regardless: render UI strings with `textContent`,\nor `html.escape` labels, so output encoding is applied uniformly.",
"id": "GHSA-2658-85cv-9wwq",
"modified": "2026-10-19T17:01:20Z",
"published": "2026-10-19T17:01:20Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/mljar/mercury/security/advisories/GHSA-2658-85cv-9wwq"
},
{
"type": "WEB",
"url": "https://github.com/mljar/mercury/commit/c798a85924e6614c6fea7afe5a560e4554a1d09b"
},
{
"type": "PACKAGE",
"url": "https://github.com/mljar/mercury"
},
{
"type": "WEB",
"url": "https://github.com/mljar/mercury/releases/tag/v3.2.11"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Mercury: Stored / DOM Cross-Site Scripting (XSS): mr.Markdown renders untrusted content as raw, unsanitized HTML"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.