Stored cross site scripting in Markdown Preview in JupyterLab
Description
JupyterLab is an extensible environment for interactive and reproducible computing, based on the Jupyter Notebook and Architecture. This vulnerability depends on user interaction by opening a malicious Markdown file using JupyterLab preview feature. A malicious user can access any data that the attacked user has access to as well as perform arbitrary requests acting as the attacked user. JupyterLab version 4.0.11 has been patched. Users are advised to upgrade. Users unable to upgrade should disable the table of contents extension.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
A cross-site scripting (XSS) vulnerability in JupyterLab's Markdown preview lets attackers execute arbitrary requests as the victim by tricking them into opening a crafted .md file.
Vulnerability
Overview
CVE-2024-22420 is a security vulnerability in JupyterLab's Markdown preview feature, stemming from insufficient sanitization of HTML content rendered by the Markdown viewer. The bug allows a malicious Markdown file to inject arbitrary JavaScript when the victim opens it using the JupyterLab file preview functionality. This issue is rooted in how the Markdown table of contents extension renders untrusted content without proper sanitization [1][2].
Exploitation
Path
Exploitation requires user interaction: the attacker must convince the victim to open a specially crafted Markdown file in JupyterLab's preview pane. The vulnerability does not require any authentication bypass or special network position beyond the ability to deliver a .md file to the target (e.g., via a shared notebook, repository, or email link). Once the file is previewed, the injected script executes within the context of the JupyterLab application [3].
Impact
A successful attack enables the attacker to perform arbitrary requests as the victim, including accessing any data the victim has access to within the Jupyter environment. This could lead to unauthorized data exfiltration, modification of notebooks, or further compromise of the JupyterLab session [3].
Mitigation
The vulnerability is patched in JupyterLab version 4.0.11. Users are strongly advised to upgrade. For those unable to upgrade immediately, disabling the table of contents extension serves as a temporary workaround to block the attack vector [3].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
jupyterlabPyPI | >= 4.0.0, < 4.0.11 | 4.0.11 |
notebookPyPI | >= 7.0.0, < 7.0.7 | 7.0.7 |
Affected products
6- osv-coords5 versionspkg:bitnami/jupyter-base-notebookpkg:bitnami/jupyterlabpkg:bitnami/jupyter-notebookpkg:pypi/jupyterlabpkg:pypi/notebook
>= 7.0.0+ 4 more
- (no CPE)range: >= 7.0.0
- (no CPE)range: >= 4.0.0, < 4.2.4
- (no CPE)range: >= 7.0.0, < 7.0.7
- (no CPE)range: >= 4.0.0, < 4.0.11
- (no CPE)range: >= 7.0.0, < 7.0.7
- jupyterlab/jupyterlabv5Range: >=4.0.0, < 4.0.11
Patches
2dda0033cd494Merge pull request from GHSA-4m77-cmpx-vjc4
11 files changed · +85 −26
packages/markdownviewer-extension/src/index.ts+12 −4 modified@@ -10,7 +10,7 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { WidgetTracker } from '@jupyterlab/apputils'; +import { ISanitizer, WidgetTracker } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; import { IMarkdownViewerTracker, @@ -20,6 +20,7 @@ import { MarkdownViewerTableOfContentsFactory } from '@jupyterlab/markdownviewer'; import { + IRenderMime, IRenderMimeRegistry, markdownRendererFactory } from '@jupyterlab/rendermime'; @@ -49,7 +50,12 @@ const plugin: JupyterFrontEndPlugin<IMarkdownViewerTracker> = { description: 'Adds markdown file viewer and provides its tracker.', provides: IMarkdownViewerTracker, requires: [IRenderMimeRegistry, ITranslator], - optional: [ILayoutRestorer, ISettingRegistry, ITableOfContentsRegistry], + optional: [ + ILayoutRestorer, + ISettingRegistry, + ITableOfContentsRegistry, + ISanitizer + ], autoStart: true }; @@ -62,7 +68,8 @@ function activate( translator: ITranslator, restorer: ILayoutRestorer | null, settingRegistry: ISettingRegistry | null, - tocRegistry: ITableOfContentsRegistry | null + tocRegistry: ITableOfContentsRegistry | null, + sanitizer: IRenderMime.ISanitizer | null ): IMarkdownViewerTracker { const trans = translator.load('jupyterlab'); const { commands, docRegistry } = app; @@ -182,7 +189,8 @@ function activate( tocRegistry.add( new MarkdownViewerTableOfContentsFactory( tracker, - rendermime.markdownParser + rendermime.markdownParser, + sanitizer ?? rendermime.sanitizer ) ); }
packages/markdownviewer/src/toc.ts+6 −4 modified@@ -2,7 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { IWidgetTracker } from '@jupyterlab/apputils'; -import { IMarkdownParser } from '@jupyterlab/rendermime'; +import { IMarkdownParser, IRenderMime } from '@jupyterlab/rendermime'; import { TableOfContents, TableOfContentsFactory, @@ -96,7 +96,8 @@ export class MarkdownViewerTableOfContentsFactory extends TableOfContentsFactory */ constructor( tracker: IWidgetTracker<MarkdownDocument>, - protected parser: IMarkdownParser | null + protected parser: IMarkdownParser | null, + protected sanitizer: IRenderMime.ISanitizer ) { super(tracker); } @@ -165,13 +166,14 @@ export class MarkdownViewerTableOfContentsFactory extends TableOfContentsFactory const elementId = await TableOfContentsUtils.Markdown.getHeadingId( this.parser!, heading.raw, - heading.level + heading.level, + this.sanitizer ); if (!elementId) { return; } - const selector = `h${heading.level}[id="${elementId}"]`; + const selector = `h${heading.level}[id="${CSS.escape(elementId)}"]`; headingToElement.set( heading,
packages/notebook/src/toc.ts+10 −4 modified@@ -588,10 +588,14 @@ export class NotebookToCFactory extends TableOfContentsFactory<NotebookPanel> { const findHeadingElement = (cell: Cell): void => { model.getCellHeadings(cell).forEach(async heading => { - const elementId = await getIdForHeading(heading, this.parser!); + const elementId = await getIdForHeading( + heading, + this.parser!, + this.sanitizer + ); const selector = elementId - ? `h${heading.level}[id="${elementId}"]` + ? `h${heading.level}[id="${CSS.escape(elementId)}"]` : `h${heading.level}`; if (heading.outputIndex !== undefined) { @@ -710,15 +714,17 @@ export class NotebookToCFactory extends TableOfContentsFactory<NotebookPanel> { */ export async function getIdForHeading( heading: INotebookHeading, - parser: IRenderMime.IMarkdownParser + parser: IRenderMime.IMarkdownParser, + sanitizer: IRenderMime.ISanitizer ) { let elementId: string | null = null; if (heading.type === Cell.HeadingType.Markdown) { elementId = await TableOfContentsUtils.Markdown.getHeadingId( parser, // Type from TableOfContentsUtils.Markdown.IMarkdownHeading (heading as any).raw, - heading.level + heading.level, + sanitizer ); } else if (heading.type === Cell.HeadingType.HTML) { // Type from TableOfContentsUtils.IHTMLHeading
packages/notebook/src/widget.ts+3 −2 modified@@ -2191,14 +2191,15 @@ export class Notebook extends StaticNotebook { id = await TableOfContentsUtils.Markdown.getHeadingId( this.rendermime.markdownParser!, mdHeading.raw, - mdHeading.level + mdHeading.level, + this.rendermime.sanitizer ); } break; } if (id === queryId) { const element = this.node.querySelector( - `h${heading.level}[id="${id}"]` + `h${heading.level}[id="${CSS.escape(id)}"]` ) as HTMLElement; return {
packages/rendermime-interfaces/src/index.ts+2 −2 modified@@ -486,10 +486,10 @@ export namespace IRenderMime { */ export interface IMarkdownParser { /** - * Render a markdown source. + * Render a markdown source into unsanitized HTML. * * @param source - The string to render. - * @returns - A promise of the string. + * @returns - A promise of the string containing HTML which may require sanitization. */ render(source: string): Promise<string>; }
packages/toc/package.json+1 −0 modified@@ -46,6 +46,7 @@ "@jupyterlab/docregistry": "^4.0.10", "@jupyterlab/observables": "^5.0.10", "@jupyterlab/rendermime": "^4.0.10", + "@jupyterlab/rendermime-interfaces": "^3.8.10", "@jupyterlab/translation": "^4.0.10", "@jupyterlab/ui-components": "^4.0.10", "@lumino/coreutils": "^2.1.2",
packages/toc/src/utils/markdown.ts+20 −10 modified@@ -1,7 +1,9 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. +import { Sanitizer } from '@jupyterlab/apputils'; import { IMarkdownParser, renderMarkdown } from '@jupyterlab/rendermime'; +import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; import { TableOfContents } from '../tokens'; /** @@ -24,27 +26,35 @@ export interface IMarkdownHeading extends TableOfContents.IHeading { * * @param raw Raw markdown heading * @param level Heading level + * @param sanitizer HTML sanitizer */ export async function getHeadingId( - parser: IMarkdownParser, + markdownParser: IMarkdownParser, raw: string, - level: number + level: number, + sanitizer?: IRenderMime.ISanitizer ): Promise<string | null> { try { - const innerHTML = await parser.render(raw); + const host = document.createElement('div'); - if (!innerHTML) { - return null; - } + await renderMarkdown({ + markdownParser, + host, + source: raw, + trusted: false, + sanitizer: sanitizer ?? new Sanitizer(), + shouldTypeset: false, + resolver: null, + linkHandler: null, + latexTypesetter: null + }); - const container = document.createElement('div'); - container.innerHTML = innerHTML; - const header = container.querySelector(`h${level}`); + const header = host.querySelector(`h${level}`); if (!header) { return null; } - return renderMarkdown.createHeaderId(header); + return header.id; } catch (reason) { console.error('Failed to parse a heading.', reason); }
packages/toc/test/markdown.spec.ts+24 −0 modified@@ -2,9 +2,33 @@ // Distributed under the terms of the Modified BSD License. import { TableOfContentsUtils } from '@jupyterlab/toc'; +import { Sanitizer } from '@jupyterlab/apputils'; +import { createMarkdownParser } from '@jupyterlab/markedparser-extension'; +import { IMarkdownParser } from '@jupyterlab/rendermime'; +import { + EditorLanguageRegistry, + IEditorLanguageRegistry +} from '@jupyterlab/codemirror'; describe('TableOfContentsUtils', () => { describe('Markdown', () => { + describe('#getHeadingId', () => { + const languages: IEditorLanguageRegistry = new EditorLanguageRegistry(); + const parser: IMarkdownParser = createMarkdownParser(languages); + const sanitizer = new Sanitizer(); + it.each<[string, string]>([ + ['# Title', 'Title'], + [`# test'"></title><img>test {#'"><img>}`, `test'\">test-{#'\">}`] + ])('should derive ID from markdown', async (markdown, expectedId) => { + const headingId = await TableOfContentsUtils.Markdown.getHeadingId( + parser, + markdown, + 1, + sanitizer + ); + expect(headingId).toEqual(expectedId); + }); + }); describe('#getHeadings', () => { it.each<[string, TableOfContentsUtils.Markdown.IMarkdownHeading[]]>([ [
packages/toc/tsconfig.json+3 −0 modified@@ -26,6 +26,9 @@ }, { "path": "../ui-components" + }, + { + "path": "../rendermime-interfaces" } ] }
packages/toc/tsconfig.test.json+3 −0 modified@@ -26,6 +26,9 @@ { "path": "../ui-components" }, + { + "path": "../rendermime-interfaces" + }, { "path": "." },
yarn.lock+1 −0 modified@@ -4757,6 +4757,7 @@ __metadata: "@jupyterlab/docregistry": ^4.0.10 "@jupyterlab/observables": ^5.0.10 "@jupyterlab/rendermime": ^4.0.10 + "@jupyterlab/rendermime-interfaces": ^3.8.10 "@jupyterlab/testing": ^4.0.10 "@jupyterlab/translation": ^4.0.10 "@jupyterlab/ui-components": ^4.0.10
e1b3aabab603Merge pull request from GHSA-4m77-cmpx-vjc4
11 files changed · +85 −26
packages/markdownviewer-extension/src/index.ts+12 −4 modified@@ -10,7 +10,7 @@ import { JupyterFrontEnd, JupyterFrontEndPlugin } from '@jupyterlab/application'; -import { WidgetTracker } from '@jupyterlab/apputils'; +import { ISanitizer, WidgetTracker } from '@jupyterlab/apputils'; import { PathExt } from '@jupyterlab/coreutils'; import { IMarkdownViewerTracker, @@ -20,6 +20,7 @@ import { MarkdownViewerTableOfContentsFactory } from '@jupyterlab/markdownviewer'; import { + IRenderMime, IRenderMimeRegistry, markdownRendererFactory } from '@jupyterlab/rendermime'; @@ -49,7 +50,12 @@ const plugin: JupyterFrontEndPlugin<IMarkdownViewerTracker> = { description: 'Adds markdown file viewer and provides its tracker.', provides: IMarkdownViewerTracker, requires: [IRenderMimeRegistry, ITranslator], - optional: [ILayoutRestorer, ISettingRegistry, ITableOfContentsRegistry], + optional: [ + ILayoutRestorer, + ISettingRegistry, + ITableOfContentsRegistry, + ISanitizer + ], autoStart: true }; @@ -62,7 +68,8 @@ function activate( translator: ITranslator, restorer: ILayoutRestorer | null, settingRegistry: ISettingRegistry | null, - tocRegistry: ITableOfContentsRegistry | null + tocRegistry: ITableOfContentsRegistry | null, + sanitizer: IRenderMime.ISanitizer | null ): IMarkdownViewerTracker { const trans = translator.load('jupyterlab'); const { commands, docRegistry } = app; @@ -182,7 +189,8 @@ function activate( tocRegistry.add( new MarkdownViewerTableOfContentsFactory( tracker, - rendermime.markdownParser + rendermime.markdownParser, + sanitizer ?? rendermime.sanitizer ) ); }
packages/markdownviewer/src/toc.ts+6 −4 modified@@ -2,7 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { IWidgetTracker } from '@jupyterlab/apputils'; -import { IMarkdownParser } from '@jupyterlab/rendermime'; +import { IMarkdownParser, IRenderMime } from '@jupyterlab/rendermime'; import { TableOfContents, TableOfContentsFactory, @@ -96,7 +96,8 @@ export class MarkdownViewerTableOfContentsFactory extends TableOfContentsFactory */ constructor( tracker: IWidgetTracker<MarkdownDocument>, - protected parser: IMarkdownParser | null + protected parser: IMarkdownParser | null, + protected sanitizer: IRenderMime.ISanitizer ) { super(tracker); } @@ -165,13 +166,14 @@ export class MarkdownViewerTableOfContentsFactory extends TableOfContentsFactory const elementId = await TableOfContentsUtils.Markdown.getHeadingId( this.parser!, heading.raw, - heading.level + heading.level, + this.sanitizer ); if (!elementId) { return; } - const selector = `h${heading.level}[id="${elementId}"]`; + const selector = `h${heading.level}[id="${CSS.escape(elementId)}"]`; headingToElement.set( heading,
packages/notebook/src/toc.ts+10 −4 modified@@ -649,10 +649,14 @@ export class NotebookToCFactory extends TableOfContentsFactory<NotebookPanel> { const findHeadingElement = (cell: Cell): void => { model.getCellHeadings(cell).forEach(async heading => { - const elementId = await getIdForHeading(heading, this.parser!); + const elementId = await getIdForHeading( + heading, + this.parser!, + this.sanitizer + ); const selector = elementId - ? `h${heading.level}[id="${elementId}"]` + ? `h${heading.level}[id="${CSS.escape(elementId)}"]` : `h${heading.level}`; if (heading.outputIndex !== undefined) { @@ -771,15 +775,17 @@ export class NotebookToCFactory extends TableOfContentsFactory<NotebookPanel> { */ export async function getIdForHeading( heading: INotebookHeading, - parser: IRenderMime.IMarkdownParser + parser: IRenderMime.IMarkdownParser, + sanitizer: IRenderMime.ISanitizer ) { let elementId: string | null = null; if (heading.type === Cell.HeadingType.Markdown) { elementId = await TableOfContentsUtils.Markdown.getHeadingId( parser, // Type from TableOfContentsUtils.Markdown.IMarkdownHeading (heading as any).raw, - heading.level + heading.level, + sanitizer ); } else if (heading.type === Cell.HeadingType.HTML) { // Type from TableOfContentsUtils.IHTMLHeading
packages/notebook/src/widget.ts+3 −2 modified@@ -2305,14 +2305,15 @@ export class Notebook extends StaticNotebook { id = await TableOfContentsUtils.Markdown.getHeadingId( this.rendermime.markdownParser!, mdHeading.raw, - mdHeading.level + mdHeading.level, + this.rendermime.sanitizer ); } break; } if (id === queryId) { const element = this.node.querySelector( - `h${heading.level}[id="${id}"]` + `h${heading.level}[id="${CSS.escape(id)}"]` ) as HTMLElement; return {
packages/rendermime-interfaces/src/index.ts+2 −2 modified@@ -525,10 +525,10 @@ export namespace IRenderMime { */ export interface IMarkdownParser { /** - * Render a markdown source. + * Render a markdown source into unsanitized HTML. * * @param source - The string to render. - * @returns - A promise of the string. + * @returns - A promise of the string containing HTML which may require sanitization. */ render(source: string): Promise<string>; }
packages/toc/package.json+1 −0 modified@@ -46,6 +46,7 @@ "@jupyterlab/docregistry": "^4.1.0-beta.1", "@jupyterlab/observables": "^5.1.0-beta.1", "@jupyterlab/rendermime": "^4.1.0-beta.1", + "@jupyterlab/rendermime-interfaces": "^3.9.0-beta.1", "@jupyterlab/translation": "^4.1.0-beta.1", "@jupyterlab/ui-components": "^4.1.0-beta.1", "@lumino/coreutils": "^2.1.2",
packages/toc/src/utils/markdown.ts+20 −10 modified@@ -1,7 +1,9 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. +import { Sanitizer } from '@jupyterlab/apputils'; import { IMarkdownParser, renderMarkdown } from '@jupyterlab/rendermime'; +import { IRenderMime } from '@jupyterlab/rendermime-interfaces'; import { TableOfContents } from '../tokens'; /** @@ -24,27 +26,35 @@ export interface IMarkdownHeading extends TableOfContents.IHeading { * * @param raw Raw markdown heading * @param level Heading level + * @param sanitizer HTML sanitizer */ export async function getHeadingId( - parser: IMarkdownParser, + markdownParser: IMarkdownParser, raw: string, - level: number + level: number, + sanitizer?: IRenderMime.ISanitizer ): Promise<string | null> { try { - const innerHTML = await parser.render(raw); + const host = document.createElement('div'); - if (!innerHTML) { - return null; - } + await renderMarkdown({ + markdownParser, + host, + source: raw, + trusted: false, + sanitizer: sanitizer ?? new Sanitizer(), + shouldTypeset: false, + resolver: null, + linkHandler: null, + latexTypesetter: null + }); - const container = document.createElement('div'); - container.innerHTML = innerHTML; - const header = container.querySelector(`h${level}`); + const header = host.querySelector(`h${level}`); if (!header) { return null; } - return renderMarkdown.createHeaderId(header); + return header.id; } catch (reason) { console.error('Failed to parse a heading.', reason); }
packages/toc/test/markdown.spec.ts+24 −0 modified@@ -2,9 +2,33 @@ // Distributed under the terms of the Modified BSD License. import { TableOfContentsUtils } from '@jupyterlab/toc'; +import { Sanitizer } from '@jupyterlab/apputils'; +import { createMarkdownParser } from '@jupyterlab/markedparser-extension'; +import { IMarkdownParser } from '@jupyterlab/rendermime'; +import { + EditorLanguageRegistry, + IEditorLanguageRegistry +} from '@jupyterlab/codemirror'; describe('TableOfContentsUtils', () => { describe('Markdown', () => { + describe('#getHeadingId', () => { + const languages: IEditorLanguageRegistry = new EditorLanguageRegistry(); + const parser: IMarkdownParser = createMarkdownParser(languages); + const sanitizer = new Sanitizer(); + it.each<[string, string]>([ + ['# Title', 'Title'], + [`# test'"></title><img>test {#'"><img>}`, `test'\">test-{#'\">}`] + ])('should derive ID from markdown', async (markdown, expectedId) => { + const headingId = await TableOfContentsUtils.Markdown.getHeadingId( + parser, + markdown, + 1, + sanitizer + ); + expect(headingId).toEqual(expectedId); + }); + }); describe('#getHeadings', () => { it.each<[string, TableOfContentsUtils.Markdown.IMarkdownHeading[]]>([ [
packages/toc/tsconfig.json+3 −0 modified@@ -26,6 +26,9 @@ }, { "path": "../ui-components" + }, + { + "path": "../rendermime-interfaces" } ] }
packages/toc/tsconfig.test.json+3 −0 modified@@ -26,6 +26,9 @@ { "path": "../ui-components" }, + { + "path": "../rendermime-interfaces" + }, { "path": "." },
yarn.lock+1 −0 modified@@ -4908,6 +4908,7 @@ __metadata: "@jupyterlab/docregistry": ^4.1.0-beta.1 "@jupyterlab/observables": ^5.1.0-beta.1 "@jupyterlab/rendermime": ^4.1.0-beta.1 + "@jupyterlab/rendermime-interfaces": ^3.9.0-beta.1 "@jupyterlab/testing": ^4.1.0-beta.1 "@jupyterlab/translation": ^4.1.0-beta.1 "@jupyterlab/ui-components": ^4.1.0-beta.1
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-4m77-cmpx-vjc4ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-22420ghsaADVISORY
- github.com/jupyterlab/jupyterlab/commit/dda0033cd49449572d077bbecd33b18d8d05f48aghsaWEB
- github.com/jupyterlab/jupyterlab/commit/e1b3aabab603878e46add445a3114e838411d2dfghsax_refsource_MISCWEB
- github.com/jupyterlab/jupyterlab/security/advisories/GHSA-4m77-cmpx-vjc4ghsax_refsource_CONFIRMWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/UQJKNRDRFMKGVRIYNNN6CKMNJDNYWO2HghsaWEB
- lists.fedoraproject.org/archives/list/package-announce@lists.fedoraproject.org/message/UQJKNRDRFMKGVRIYNNN6CKMNJDNYWO2H/mitre
News mentions
0No linked articles in our index yet.