Mercury, the open source framework from MLJAR that turns Jupyter notebooks into web apps, had two unauthenticated remote code execution bugs in a row this summer, and both came from the same design. The first was a missing comma in a Python list. The second was the endpoint that list could never block. The CVE records for both, plus a third, cross-site scripting bug, were published on 19 October 2026, weeks after the fixes shipped.
This post is for teams that publish notebooks with Mercury, and for anyone who keeps a security blocklist in Python. It covers how each bug worked, what version 3.2.6 changed, an upgrade gotcha, and a scanner we wrote for the missing-comma bug class, with what it found on a large real codebase.
The short answer
Mercury 3.0.0 through 3.2.3 let unauthenticated users open a shell through /api/terminals (CVE-2026-111057, CVSS 3.1 10.0), because a missing comma merged two entries in its endpoint blocklist; 3.2.4 fixed it on 21 August 2026. Versions up to 3.2.5 still let unauthenticated users start a kernel through /api/kernels and run any Python (CVE-2026-111058, 9.8); 3.2.6 fixed it on 28 August with an execution firewall that only runs the notebook's own cells. A stored XSS in mr.Markdown (CVE-2026-111059, 6.1) is fixed in 3.2.11. Upgrade to 3.2.11 or later.
How Mercury exposes a notebook
Mercury serves a notebook as an app on top of a Jupyter server. According to the CVE-2026-111058 advisory, when no token is given, Mercury starts that server with anonymous access, which is the point: viewers of a public dashboard shouldn't need to log in. It also turned off Jupyter's XSRF check in that mode.
A Jupyter server exposes much more than a dashboard needs: a terminal API, file access, the JupyterLab interface, and the kernel API that runs code. Up to 3.2.5, Mercury kept anonymous users away from the dangerous parts with a blocklist of URL patterns in mercury_app/block_handler.py. Anything matching was refused with a 403. Everything else went through.
CVE-2026-111057: one missing comma
Here is the blocklist as it shipped in v3.2.3:
BLOCKED_PATTERNS = [
# JupyterLab UI + API
r"/lab(?!/(?:extensions/|api/settings(?:/|$)|api/translations(?:/|$)))(?:/.*)?"
r"/api/terminals(?:/.*)?",
r"/api/shutdown(?:/.*)?",
r"/files(?:/.*)?",
]
The first entry has no comma at the end. Python joins adjacent string literals at compile time, so the first two lines became a single regular expression: the /lab pattern followed directly by /api/terminals. That combined pattern only matches a path that starts with /lab and also ends in /api/terminals, so a request to /api/terminals itself never matched it. The terminal API was never blocked, and as the CVE record puts it, an unauthenticated attacker could get an interactive operating system shell as the Mercury process.
Nothing failed. The list still looked like four entries and the code still ran. The /lab entry stopped working too, since the merged pattern needed both halves, but that was much less visible. Version 3.2.4, released 21 August, added the comma. The advisory credits szotgan.
CVE-2026-111058: the endpoint a blocklist can't block
The finder of the second bug, Abdullah Kareem, tested 3.2.4, the release with the comma fix. /api/terminals now returned 403. /api/kernels didn't, and couldn't: Mercury needs kernels to run the notebook, so the kernel API has to stay reachable for anonymous viewers.
The advisory describes the rest. An anonymous client could ask the server to start a kernel, open that kernel's websocket channel and send an execute_request containing any code. Mercury's websocket wrapper only tracked idle time and passed every message through. The result was arbitrary Python as the server user on any default, tokenless deployment. The finder's own summary is that the blocklist approach is "fundamentally unable to cover kernels", and that's the key point: no extra entry could fix it.
What 3.2.6 changed
The fix, commit 73a40ce released in 3.2.6 on 28 August, replaces "block the bad endpoints" with "allow only what the app does". Reading the diff:
- An execution firewall on the kernel websocket. A new connection class checks every incoming kernel message. An
execute_requestis only allowed if it names a code cell in the notebook being served, by cell ID, and the code that runs is taken from the server's own copy of that notebook, not from the message. The notebook's cells are hashed into a revision, so a stale page can't run old code. Two named internal actions are allowed. Everything else is refused. - Allowed channels and message types are listed. Requests on the wrong channel, unknown message types, unregistered comm IDs and oversized frames are denied.
- Sessions, kernels and contents go through Mercury's own handlers, and
/api/contentsjoined the blocklist. - XSRF protection is back on, and enforced. Mercury no longer disables the check in anonymous mode, and it refuses to start if you pass
--ServerApp.disable_check_xsrf=Trueyourself. - The mode is logged. At startup it logs
Mercury security mode:followed byanonymous-firewall, orauthenticated-firewallwhen a token or password is set.
Anonymous access is still the default, and that's deliberate for public dashboards. The difference is that an anonymous viewer can now run the notebook, not arbitrary code.
The upgrade gotcha
If your launch command, Dockerfile or service unit passes --ServerApp.disable_check_xsrf=True, perhaps copied from an older setup or to work around a proxy, Mercury 3.2.6 and later stop at startup with an error asking you to remove it. That's the safe behavior, but it can look like a broken upgrade. Remove the flag and fix whatever needed it, usually a reverse proxy that doesn't forward the right headers.
Check what you run:
python -c "import importlib.metadata as m; print(m.version('mercury'))"
grep -rn "disable_check_xsrf" Dockerfile* docker-compose*.yml *.service Procfile 2>/dev/null
Then look for the security mode line in the startup log. If it says anonymous-firewall on an app that shouldn't be public, set a token or password.
The third CVE: mr.Markdown XSS
CVE-2026-111059, credited to yuuto200511-hash, affects 3.0.0 up to 3.2.11. The mr.Markdown widget turned Markdown into HTML without stripping raw HTML, so content from one user could run script in another user's browser. It matters when an app renders text that users supply. It's fixed in 3.2.11, released 10 September, which is the version to target for all three.
Find missing commas in your own code
The first bug is a class, not a one-off. Any Python list, tuple or set of strings can lose a comma, and the result is always valid code with one fewer entry. In allowlists, blocklists, scope lists and test inputs, that missing entry is invisible.
This scanner uses Python's tokenizer, so it sees the separate literals before Python joins them. It flags a collection of plain strings where one entry is two or more literals on different lines with no comma between them. Joins that fall on a space or newline usually mean text wrapped on purpose, so those are hidden unless you pass --all. Standard library only; we ran it on Python 3.14:
#!/usr/bin/env python3
"""Find a likely missing comma in a list, set or tuple of strings: one entry
made of two string literals on different lines, next to at least one entry that is a
single literal. That is the shape of the bug behind CVE-2026-111057.
Entries where the pieces meet at a space or newline look like wrapped text and
are only shown with --all.
Usage: python3 implicit_concat.py [--all] path/ [more paths...]
"""
import ast
import io
import pathlib
import sys
import tokenize
SKIP = {tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT}
FSTART = getattr(tokenize, "FSTRING_START", None) # Python 3.12+
FEND = getattr(tokenize, "FSTRING_END", None)
CLOSE = {"[": "]", "(": ")", "{": "}"}
class Collection:
def __init__(self, bracket, is_call):
self.bracket, self.is_call, self.is_dict = bracket, is_call, False
self.items = [[]] # each item: list of string tokens, or None if not all strings
def add_string(self, tok):
if self.items[-1] is not None:
self.items[-1].append(tok)
def add_other(self):
self.items[-1] = None
def suspects(self):
items = [i for i in self.items if i != []] # drop a trailing-comma empty item
if self.is_call or self.is_dict or len(items) < 2 or None in items:
return []
singles = sum(1 for i in items if len(i) == 1)
joined = [i for i in items if len(i) > 1 and i[0].start[0] != i[-1].end[0]]
return joined if singles >= 1 else []
def wrapped_text(item):
"""True if any join between pieces falls on whitespace, like wrapped prose."""
for a, b in zip(item, item[1:]):
try:
left, right = ast.literal_eval(a.string), ast.literal_eval(b.string)
except (ValueError, SyntaxError):
return True # f-string pieces: can't tell, treat as text
if isinstance(left, bytes):
left, right = left.decode("latin-1"), right.decode("latin-1")
if left[-1:].isspace() or right[:1].isspace():
return True
return False
def scan(path, show_all=False):
src = pathlib.Path(path).read_text(encoding="utf-8", errors="replace")
try:
tokens = list(tokenize.generate_tokens(io.StringIO(src).readline))
except (tokenize.TokenError, SyntaxError) as e:
return [f"{path}: could not tokenize ({e})"]
findings, stack, prev, depth_f = [], [], None, 0
for tok in tokens:
if tok.type == FSTART:
depth_f += 1
if depth_f == 1 and stack:
stack[-1].add_string(tok)
continue
if tok.type == FEND:
depth_f -= 1
prev = tok
continue
if depth_f or tok.type in SKIP:
continue
if tok.type == tokenize.OP and tok.string in CLOSE:
if stack:
stack[-1].add_other()
is_call = prev is not None and tok.string in "([" and (
prev.type == tokenize.NAME or prev.string in ")]")
stack.append(Collection(tok.string, is_call))
elif tok.type == tokenize.OP and tok.string in ")]}":
if stack:
col = stack.pop()
for item in col.suspects():
if not show_all and wrapped_text(item):
continue
findings.append(
f"{path}:{item[0].start[0]}: entry on lines {item[0].start[0]}-{item[-1].end[0]} "
f"is {len(item)} strings joined with no comma, inside a "
f"{col.bracket}{CLOSE[col.bracket]} of single strings")
elif stack and tok.type == tokenize.OP and tok.string == ",":
stack[-1].items.append([])
elif stack and tok.type == tokenize.OP and tok.string == ":":
stack[-1].is_dict = True
elif stack and tok.type == tokenize.STRING:
stack[-1].add_string(tok)
elif stack:
stack[-1].add_other()
prev = tok
return findings
def main():
hits = 0
show_all = "--all" in sys.argv
for arg in [a for a in sys.argv[1:] if a != "--all"]:
p = pathlib.Path(arg)
for f in sorted(p.rglob("*.py")) if p.is_dir() else [p]:
for line in scan(f, show_all):
hits += 1
print(line)
print(f"{hits} possible missing comma(s)")
return 1 if hits else 0
if __name__ == "__main__":
sys.exit(main())
On Mercury's own file before and after the fix, plus two small fixtures (one with wrapped text that should pass, one with two planted missing commas):
block_handler_v3.2.3.py:8: entry on lines 8-9 is 2 strings joined with no comma, inside a [] of single strings
fx/bad_tuple.py:3: entry on lines 3-4 is 2 strings joined with no comma, inside a () of single strings
fx/bad_tuple.py:6: entry on lines 6-7 is 2 strings joined with no comma, inside a {} of single strings
3 possible missing comma(s)
It flags the v3.2.3 blocklist and not v3.2.4. The wrapped-text fixture passes.
To see how it behaves on real code, we ran it on a large, mature open source Python codebase of 1,870 files. Outside the test suite it reported nothing. Inside the tests it reported 72 entries. We reviewed the 17 that join exactly two literals: 11 were long values split on purpose, and 6 looked like genuine missing commas, where two separate test inputs had been merged into one. A merged test case doesn't fail. It just stops testing one of the two inputs. We haven't reviewed the 55 longer joins, which are mostly long data values.
Run it in CI with the exit code, and treat each hit as a question: did you mean one string here, or two? Linters can catch some of these too, so it's worth checking whether yours has an implicit concatenation rule turned on.
Static analysis is good at bugs like this one, where the code is valid and wrong. Here's Safeguard's interactive walkthrough of a SAST code scan:
What to do
- Upgrade Mercury to 3.2.11 or later. Anything before 3.2.6 is exposed to at least one unauthenticated RCE.
- Remove any
disable_check_xsrfflag before upgrading, so the new version starts. - Set a token or password on any app that isn't meant to be public, and confirm the log says
authenticated-firewall. - Review old exposed instances as possibly compromised. If a tokenless instance before 3.2.6 was reachable from the internet, check the host and rotate any secrets the server process could read.
- Scan your own blocklists and allowlists for missing commas, and prefer allowlists where you can.
If you run notebooks on shared servers, our post on whether JupyterLab extensions are safe to install on a shared server covers the wider exposure.
The fair view
Mercury's maintainers handled this well. The terminal fix and its advisory went out on 21 August, the kernel fix a week later, and the kernel fix is a real redesign rather than another pattern. The CVE records came weeks later, on 19 October, the same day the advisories reached GitHub's global advisory database. Dependency scanners that read those databases had nothing to match before then, so teams that followed Mercury's releases were safe well before their tools would have told them.
The broader lesson is about the starting point. Running a full Jupyter server and subtracting the dangerous parts means every new or forgotten endpoint is open by default, and one typo can reopen an old one. Mercury's new design starts from what a dashboard viewer must be able to do and refuses the rest. That's harder to build, and much harder to get wrong by one character.
Check yourself
Why didn't the missing comma cause an error?
Because adjacent string literals are valid Python. They are joined into one string at compile time, so the list had one long, never-matching pattern instead of two working ones. The code ran normally and /api/terminals was simply never blocked.
Why couldn't 3.2.4 fix CVE-2026-111058 by adding /api/kernels to the blocklist?
Because Mercury needs the kernel API to run the notebook for every viewer. Blocking it would break the app. The fix had to control what code the kernel runs, which is what the 3.2.6 execution firewall does.
After upgrading to 3.2.6, Mercury refuses to start. What's the likely cause?
Your launch command or configuration passes --ServerApp.disable_check_xsrf=True. Mercury now requires XSRF protection in standalone mode and stops at startup if it's disabled. Remove the flag.