Safeguard
Vulnerability Analysis•

JupyterLab CVE-2026-102832: How a Trust Prompt Ended Up Trusting the Wrong Document

JupyterLab's command-link trust prompt could trust whichever document was current, not the clicked one. Versions, the fix, a test pattern and a checker.

Safeguard Security Team
Security Research
11 min read

In April, JupyterLab learned that a button inside a shared notebook could run any JupyterLab command on a single click, including code in a kernel (CVE-2026-42557). The fix added a prompt: the first click on such a button names the command and offers Cancel, Run Once or Trust. In September the project fixed a flaw in that prompt (CVE-2026-102832). Pressing Trust marked whichever document was current when you answered, not the one whose button you clicked.

It's a medium-severity bug with a narrow window, and the maintainers say so. But it's a clean example of a mistake that turns up in any confirmation dialog: the decision is applied to a target looked up after the user answers, rather than the one they were asked about. This teardown is for teams that run JupyterLab, Notebook 7 or JupyterLite, and for anyone building trust prompts. It covers both advisories, how the race works, why it lasts for notebooks, the fix, a small model with a test that catches it, and a checker for your environments and documents.

The short answer

Upgrade to JupyterLab 4.5.11 or 4.6.4, Notebook 7.6.3, or jupyterlite-core 0.8.4. JupyterLab 4.5.7 to 4.6.3 had a trust prompt that resolved its target when the user answered, so if another document became current while the prompt was open, that document was trusted instead. For notebooks, trust is saved and signed, so it lasts beyond the session. Every JupyterLab before 4.5.11 or 4.6.4 is affected by either this bug or the April one it followed. If you can't upgrade, choose Run Once rather than Trust, or turn off command links with the allowCommandLinker setting.

Two advisories, one feature

JupyterLab's command linker lets a Markdown file or notebook output include a button that runs a JupyterLab command, which is useful for tutorials and navigation. The sanitizer allowed the data-commandlinker-command attribute on buttons, and the linker ran whatever command a clicked element named.

CVE-2026-42557CVE-2026-102832
ProblemA button in shared content runs any command on one click, no promptThe new prompt's Trust can mark a different document
SeverityHigh (CVSS 4.0: 8.6)Medium (CVSS 3.1: 4.2)
JupyterLab affectedup to 4.5.64.5.7 to 4.5.10, 4.6.0 to 4.6.3
Fixed4.5.7 (29 April)4.5.11 and 4.6.4 (21 September)
Notebook affected7.0.0 to 7.5.57.5.6 to 7.6.2 (fixed 7.6.3)
JupyterLitenot listedjupyterlite-core 0.7.5 to 0.8.3 (fixed 0.8.4)
Reported byfg0x0rexpository

The April advisory described the stakes. A shared notebook could carry a button that looked like a normal widget and, on one click, execute code in the available kernels or delete files, with no kernel even needing to start, because HTML outputs are stored in the file and rendered on open. Version 4.5.7 added the prompt and a setting to disable command links entirely.

GitHub's global advisory database added the September advisory on 15 October, more than three weeks after the project published it on 22 September. Teams that rely only on database-fed scanners saw it late.

How the race works

When you clicked a command-link button in an untrusted document, JupyterLab showed the prompt. If you chose Trust, it ran a trust command, markdownviewer:trust for Markdown previews or notebook:trust for notebooks, and then the original command. The trust commands decided which document to trust by looking up the current widget, tracker.currentWidget, at the moment they ran.

That moment is after the user answers. The prompt can stay open for seconds, and during that time something else can change focus. The advisory lists realistic cases: a workspace restoring at startup, an extension activating a widget, or a command that opens a file. If a different document was current when you pressed Trust, that document was marked trusted. The one you were reading stayed untrusted and would prompt again.

The command you approved still ran with your consent, since the prompt named it. The harm is the stray trust: every command-link button in the wrongly trusted document now runs on a single click with no prompt, which is the exact state the April fix existed to prevent. An attacker would need to control that second document and the timing, which is why the project rates it medium and calls the fix defense in depth.

Why it lasts for notebooks

A trusted Markdown preview stays trusted only until you close it. Notebooks are different. notebook:trust marks every cell trusted and saves the file, and the save causes the server to sign the notebook, so it opens as trusted next time. The save also writes any unsaved edits in that notebook. A second confirmation does appear, but according to the advisory it warns only that a trusted notebook may execute hidden code and doesn't name the file. So the user gets no chance to notice that the notebook about to be signed isn't the one they meant.

The fix

The patched versions bind the decision to the clicked document. The command linker now passes the clicked content's DOM boundary ID to the trust command as an argument, commandLinkerTrustBoundaryId. The Markdown viewer's trust command finds the widget with that ID instead of reading tracker.currentWidget, and the notebook command does the equivalent. The linker then runs the original command only after confirming that the same boundary was marked trusted. If the clicked document wasn't trusted, nothing runs.

The lesson generalizes: a confirmation dialog should capture its target when it opens, carry that target through the decision, and verify it before acting. Looking it up again later is a time-of-check to time-of-use bug, the same class as a file checked and then opened by name. Our TOCTOU explainer covers the wider pattern.

A test that catches it

This model reproduces the two handlers in a few lines of Python and checks both against one rule: Trust must mark the document that was clicked, and only that one. It's not JupyterLab code:

"""A model of the JupyterLab trust-prompt race (CVE-2026-102832). Not JupyterLab code.

A click opens a prompt. While it is open, another document can become current.
The buggy handler resolves "which document" when the user answers; the fixed
one captures it at click time and checks it before running the command.
"""
import unittest


class Workspace:
    def __init__(self, *docs):
        self.docs = {d: False for d in docs}  # name -> trusted
        self.current = docs[0]


def answer_trust_buggy(ws, clicked):
    target = ws.current            # looked up when the user answers
    ws.docs[target] = True
    return target


def answer_trust_fixed(ws, clicked):
    target = clicked               # captured when the user clicked
    ws.docs[target] = True
    if not ws.docs[clicked]:       # run the command only if the clicked doc is marked
        raise RuntimeError("clicked document not trusted")
    return target


class TrustPromptTest(unittest.TestCase):
    """Same rule for both handlers: Trust must mark the document that was clicked."""

    def check(self, answer):
        ws = Workspace("report.md", "shared.ipynb")
        clicked = ws.current               # user clicks a button in report.md
        ws.current = "shared.ipynb"        # a restore or extension focuses another doc
        answer(ws, clicked)                # user presses Trust
        self.assertTrue(ws.docs["report.md"], "clicked document not trusted")
        self.assertFalse(ws.docs["shared.ipynb"], "other document trusted")

    def test_buggy_handler(self):
        self.check(answer_trust_buggy)

    def test_fixed_handler(self):
        self.check(answer_trust_fixed)


if __name__ == "__main__":
    unittest.main(verbosity=2)
$ python3 trust_prompt_model.py
test_buggy_handler (__main__.TrustPromptTest.test_buggy_handler) ... FAIL
test_fixed_handler (__main__.TrustPromptTest.test_fixed_handler) ... ok

======================================================================
FAIL: test_buggy_handler (__main__.TrustPromptTest.test_buggy_handler)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "trust_prompt_model.py", line 42, in test_buggy_handler
    self.check(answer_trust_buggy)
    ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
  File "trust_prompt_model.py", line 38, in check
    self.assertTrue(ws.docs["report.md"], "clicked document not trusted")
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError: False is not true : clicked document not trusted

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (failures=1)

The buggy handler fails because focus moved while the prompt was open. If you build prompts like this, the test shape is the useful part. Change the state the prompt depends on between showing it and answering it, and assert that the answer still applies to the original target.

Check your environments and documents

This script reads dist-info metadata in the site-packages directories you give it and flags JupyterLab, Notebook and JupyterLite versions affected by either advisory. It reports whether an overrides.json disables command links, and lists notebooks and Markdown files that contain command-link buttons. It reads files only and uses the Python standard library:

#!/usr/bin/env python3
"""Check Jupyter environments and documents for the command-link trust bugs.

1. Reads *.dist-info metadata in the given site-packages directories and flags
   jupyterlab, notebook and jupyterlite-core versions affected by
   CVE-2026-42557 (command links run without a prompt) or CVE-2026-102832
   (the trust prompt can mark the wrong document).
2. Reports whether an overrides.json under the given prefixes disables the
   command linker.
3. Lists .ipynb and .md files that contain command-link buttons, and the
   commands they name.
Reads files only. Standard library only.
Usage: jupyter_trust_check.py --site DIR [--site DIR] [--prefix DIR] [--docs DIR]
"""
import argparse, json, re
from pathlib import Path

# package: list of (first affected, last affected, fixed, CVE)
RANGES = {
    "jupyterlab": [((0,), (4, 5, 6), "4.5.7", "CVE-2026-42557"),
                   ((4, 5, 7), (4, 5, 10), "4.5.11", "CVE-2026-102832"),
                   ((4, 6, 0), (4, 6, 3), "4.6.4", "CVE-2026-102832")],
    "notebook": [((7, 0, 0), (7, 5, 5), "7.5.6", "CVE-2026-42557"),
                 ((7, 5, 6), (7, 6, 2), "7.6.3", "CVE-2026-102832")],
    "jupyterlite-core": [((0, 7, 5), (0, 8, 3), "0.8.4", "CVE-2026-102832")],
}
LINK = re.compile(r'data-commandlinker-command=\\?["\']([^"\'\\]+)')


def ver(s):
    return tuple(int(x) for x in re.findall(r"\d+", s.split("a")[0].split("b")[0].split("rc")[0])[:3])


def installed(site):
    for meta in Path(site).glob("*.dist-info/METADATA"):
        head = meta.read_text(errors="ignore")[:2000]
        name = re.search(r"^Name: (.+)$", head, re.M)
        version = re.search(r"^Version: (.+)$", head, re.M)
        if name and version:
            yield name.group(1).strip().lower().replace("_", "-"), version.group(1).strip()


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--site", action="append", default=[])
    ap.add_argument("--prefix", action="append", default=[])
    ap.add_argument("--docs")
    a = ap.parse_args()
    findings = 0
    for site in a.site:
        for name, v in installed(site):
            for lo, hi, fixed, cve in RANGES.get(name, []):
                if lo <= ver(v) <= hi:
                    findings += 1
                    print(f"UPGRADE {name} {v} in {site}: {cve}, fixed in {fixed}")
    for prefix in a.prefix:
        f = Path(prefix) / "share/jupyter/lab/settings/overrides.json"
        cfg = json.loads(f.read_text()) if f.exists() else {}
        off = cfg.get("@jupyterlab/apputils-extension:sanitizer", {}).get("allowCommandLinker") is False
        print(f"{'OK     ' if off else 'INFO   '} {prefix}: command linker {'disabled' if off else 'enabled (default)'}")
    if a.docs:
        for doc in sorted(Path(a.docs).rglob("*")):
            if doc.suffix in (".ipynb", ".md") and doc.is_file():
                cmds = sorted(set(LINK.findall(doc.read_text(errors="ignore"))))
                if cmds:
                    findings += 1
                    print(f"REVIEW  {doc.relative_to(a.docs)}: command-link buttons for {', '.join(cmds)}")
    print(f"{findings} finding(s)")
    return 1 if findings else 0


if __name__ == "__main__":
    raise SystemExit(main())

On two test environments and a folder of documents:

$ python3 jupyter_trust_check.py --site env-a/site-packages --site env-b/site-packages \
    --prefix env-a --prefix env-b --docs docs
UPGRADE notebook 7.6.1 in env-a/site-packages: CVE-2026-102832, fixed in 7.6.3
UPGRADE jupyterlab 4.6.2 in env-a/site-packages: CVE-2026-102832, fixed in 4.6.4
UPGRADE jupyterlab 4.5.5 in env-b/site-packages: CVE-2026-42557, fixed in 4.5.7
OK      env-a: command linker disabled
INFO    env-b: command linker enabled (default)
REVIEW  README.md: command-link buttons for filebrowser:open-path
REVIEW  shared-analysis.ipynb: command-link buttons for help:about
5 finding(s)

Use python3 -c "import site; print(site.getsitepackages())" inside each environment, including JupyterHub images and conda environments, to find the directories to pass. The document scan isn't a malware detector. Many legitimate tutorials use command links. But a shared notebook with a command-link button is worth a look before anyone clicks Trust. Note that the allowCommandLinker setting only exists from 4.5.7, so on older versions the upgrade is the only fix.

What to do

  1. Upgrade JupyterLab to 4.5.11 or 4.6.4, Notebook to 7.6.3, and jupyterlite-core to 0.8.4. Rebuild container images and JupyterHub single-user images, not just laptops.
  2. Prefer Run Once for buttons in documents you didn't write, on any version. It runs the command without trusting the document.
  3. Disable command links where you don't need them. Set allowCommandLinker to false under @jupyterlab/apputils-extension:sanitizer in overrides.json. Command-link navigation in documentation and some extensions will stop working.
  4. Review trusted notebooks from outside sources. If a shared notebook shows as trusted and nobody remembers trusting it, re-open it untrusted.
  5. Watch project advisories, not only the database. This one took more than three weeks to reach GitHub's global database.

The fair view

The JupyterLab team handled this well. They added a prompt to close a high-severity hole, then found and fixed a subtle flaw in their own fix, published a clear advisory that spells out the narrow conditions, and shipped patches on both supported release lines the same day. The advisory is candid that exploitation is hard, and it is.

The reason to read it anyway is the pattern. Trust prompts are spreading, in notebooks, IDEs, agent tools that ask before running a command, and browser extensions. Each one is a small piece of security UI with state that can change while the user decides. The JupyterLab fix shows the right shape: capture the target at the click, carry it through, check it at the end.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.