---
id: "CVE-2026-111059"
kind: "vulnerability"
aliases: ["CVE-2026-111059", "GHSA-2658-85cv-9wwq"]
title: "Mercury: Stored / DOM Cross-Site Scripting (XSS): mr.Markdown renders untrusted content as raw, unsanitized HTML"
severity: "medium"
cvss: 6.1
epss: 0.002839999971911311
exploited: false
status: "open"
category: "package"
products: [{"name": "mercury", "fixed": ["3.2.11"], "affected": ">= 3.0.0, < 3.2.11", "ecosystem": "pypi"}]
ecosystems: ["pypi"]
tags: ["cwe-116", "cwe-79", "mercury", "notebook-apps", "package", "python", "self-hosted"]
not_affected_if: ["managed-hosting-with-unknown-patch-status", "markdown-only-from-trusted-author-text", "version-3.2.11-or-later"]
published_at: "2026-10-19T17:01:20+00:00"
first_seen_at: "2026-10-23T19:11:32+00:00"
updated_at: "2026-10-23T21:57:18+00:00"
sources: [{"source": "ghsa", "url": "https://github.com/advisories/GHSA-2658-85cv-9wwq", "first_seen_at": "2026-10-23T19:11:32+00:00"}, {"source": "nvd", "url": "https://nvd.nist.gov/vuln/detail/CVE-2026-111059", "first_seen_at": "2026-10-23T21:57:18+00:00"}]
---

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

The mr.Markdown widget in the Mercury Python package converts text to HTML without sanitizing it, so raw HTML and JavaScript in the input reach the page. An attacker who can get untrusted data (a comment, upload, or API response) into a Mercury app can run script in the browser of anyone who views that app, enabling session or token theft and actions as the victim.

_Summary, check and fix written by a model from the sources; labelled as such._

## Affected

- pypi:mercury affected >= 3.0.0, < 3.2.11 fixed 3.2.11

## How to check

Run pip show mercury and check whether the version is >= 3.0.0 and < 3.2.11.
Search app notebooks for mr.Markdown( calls whose argument comes from a database, form, upload, or API.

## Fix

Upgrade the mercury package to 3.2.11 or later.
Until upgraded, do not pass untrusted data to mr.Markdown.

## Timeline

- 2026-10-23T19:11:32+00:00 ghsa https://github.com/advisories/GHSA-2658-85cv-9wwq
- 2026-10-23T21:57:18+00:00 nvd https://nvd.nist.gov/vuln/detail/CVE-2026-111059

## From the sources

## 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):

```python
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.

```python
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

```python
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-markdown` output* into the same `ipywidgets.HTML` `value` sink the widget
  uses causes the payload to execute in headless Chromium. Execution was captured
  via Playwright's `dialog` event (which fires only on a genuine `alert()`, 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-markdown` passes `<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. Because `MarkdownWidget` *is* the `ipywidgets.HTML` sink 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**.
- A

---

_Free to read at a human pace. An API with keys and an MCP server are in preparation; early access: hello@infrastack.ai_
