CVE-2024-22420: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: 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.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2024-01-19T21:15Z
Repository: https://github.com/jupyterlab/jupyterlab
Language: TypeScript
Files changed: packages/markdownviewer-extension/src/index.ts, packages/markdownviewer/src/toc.ts, packages/notebook/src/toc.ts, packages/notebook/src/widget.ts, packages/rendermime-interfaces/src/index.ts, packages/toc/package.json, packages/toc/src/utils/markdown.ts, packages/toc/test/markdown.spec.ts, packages/toc/tsconfig.json, packages/toc/tsconfig.test.json, yarn.lock
Commit date: 2024-01-19 12:43:25 +0000
Commit message: Merge pull request from GHSA-4m77-cmpx-vjc4

* Fix sanitization and escaping for MD headings in ToC

* Add a unit test for `getHeadingId`

* Make `sanitizer` argument optional in getHeadingId, integrity update
Diff: diff --git a/packages/markdownviewer-extension/src/index.ts b/packages/markdownviewer-extension/src/index.ts
index 206dde5c47..759aa71069 100644
--- a/packages/markdownviewer-extension/src/index.ts
+++ b/packages/markdownviewer-extension/src/index.ts
@@ -8,20 +8,21 @@
 import {
   ILayoutRestorer,
   JupyterFrontEnd,
   JupyterFrontEndPlugin
 } from '@jupyterlab/application';
-import { WidgetTracker } from '@jupyterlab/apputils';
+import { ISanitizer, WidgetTracker } from '@jupyterlab/apputils';
 import { PathExt } from '@jupyterlab/coreutils';
 import {
   IMarkdownViewerTracker,
   MarkdownDocument,
   MarkdownViewer,
   MarkdownViewerFactory,
   MarkdownViewerTableOfContentsFactory
 } from '@jupyterlab/markdownviewer';
 import {
+  IRenderMime,
   IRenderMimeRegistry,
   markdownRendererFactory
 } from '@jupyterlab/rendermime';
 import { ISettingRegistry } from '@jupyterlab/settingregistry';
 import { ITableOfContentsRegistry } from '@jupyterlab/toc';
@@ -47,11 +48,16 @@ const plugin: JupyterFrontEndPlugin<IMarkdownViewerTracker> = {
   activate,
   id: '@jupyterlab/markdownviewer-extension:plugin',
   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
 };
 
 /**
  * Activate the markdown viewer plugin.
@@ -60,11 +66,12 @@ function activate(
   app: JupyterFrontEnd,
   rendermime: IRenderMimeRegistry,
   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;
 
   // Add the markdown renderer factory.
@@ -180,11 +187,12 @@ function activate(
 
   if (tocRegistry) {
     tocRegistry.add(
       new MarkdownViewerTableOfContentsFactory(
         tracker,
-        rendermime.markdownParser
+        rendermime.markdownParser,
+        sanitizer ?? rendermime.sanitizer
       )
     );
   }
 
   return tracker;
diff --git a/packages/markdownviewer/src/toc.ts b/packages/markdownviewer/src/toc.ts
index 3873b3b624..67e2ccab3f 100644
--- a/packages/markdownviewer/src/toc.ts
+++ b/packages/markdownviewer/src/toc.ts
@@ -1,10 +1,10 @@
 // Copyright (c) Jupyter Development Team.
 // 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,
   TableOfContentsModel,
   TableOfContentsUtils
@@ -94,11 +94,12 @@ export class MarkdownViewerTableOfContentsFactory extends TableOfContentsFactory
    * @param tracker Widget tracker
    * @param parser Markdown parser
    */
   constructor(
     tracker: IWidgetTracker<MarkdownDocument>,
-    protected parser: IMarkdownParser | null
+    protected parser: IMarkdownParser | null,
+    protected sanitizer: IRenderMime.ISanitizer
   ) {
     super(tracker);
   }
 
   /**
@@ -163,17 +164,18 @@ export class MarkdownViewerTableOfContentsFactory extends TableOfContentsFactory
       headingToElement = new WeakMap<IMarkdownViewerHeading, Element | null>();
       model.headings.forEach(async heading => {
         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,
           TableOfContentsUtils.addPrefix(
             widget.content.node,
diff --git a/packages/notebook/src/toc.ts b/packages/notebook/src/toc.ts
index fb653824df..3f2f7e7493 100644
--- a/packages/notebook/src/toc.ts
+++ b/packages/notebook/src/toc.ts
@@ -647,14 +647,18 @@ 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) {
           // Code cell
           headingToElement.set(
@@ -769,19 +773,21 @@ export class NotebookToCFactory extends TableOfContentsFactory<NotebookPanel> {
  * @param parser The markdownparser
  * @returns The element id
  */
 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
     elementId = (heading as any).id;
   }
diff --git a/packages/notebook/src/widget.ts b/packages/notebook/src/widget.ts
index 05af9423f3..8eb6b9060b 100644
--- a/packages/notebook/src/widget.ts
+++ b/packages/notebook/src/widget.ts
@@ -2303,18 +2303,19 @@ export class Notebook extends StaticNotebook {
               const mdHeading =
                 heading as any as TableOfContentsUtils.Markdown.IMarkdownHeading;
               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 {
             cell,
             element
diff --git a/packages/rendermime-interfaces/src/index.ts b/packages/rendermime-interfaces/src/index.ts
index 1cfa630add..90e41a09fb 100644
--- a/packages/rendermime-interfaces/src/index.ts
+++ b/packages/rendermime-interfaces/src/index.ts
@@ -523,14 +523,14 @@ export namespace IRenderMime {
   /**
    * The interface for a Markdown parser.
    */
   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>;
   }
 
   // ********************** //
diff --git a/packages/toc/package.json b/packages/toc/package.json
index f47dbbe962..b3a6150ae0 100644
--- a/packages/toc/package.json
+++ b/packages/toc/package.json
@@ -44,10 +44,11 @@
     "@jupyterlab/apputils": "^4.2.0-beta.1",
     "@jupyterlab/coreutils": "^6.1.0-beta.1",
     "@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",
     "@lumino/disposable": "^2.1.2",
     "@lumino/messaging": "^2.0.1",
diff --git a/packages/toc/src/utils/markdown.ts b/packages/toc/src/utils/markdown.ts
index 603d4473cf..92b45cc33d 100644
--- a/packages/toc/src/utils/markdown.ts
+++ b/packages/toc/src/utils/markdown.ts
@@ -1,9 +1,11 @@
 // 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';
 
 /**
  * Markdown heading
  */
@@ -22,31 +24,39 @@ export interface IMarkdownHeading extends TableOfContents.IHeading {
 /**
  * Build the heading html id.
  *
  * @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);
   }
 
   return null;
diff --git a/packages/toc/test/markdown.spec.ts b/packages/toc/test/markdown.spec.ts
index af0592d948..d8cebcefcc 100644
--- a/packages/toc/test/markdown.spec.ts
+++ b/packages/toc/test/markdown.spec.ts
@@ -1,12 +1,36 @@
 // Copyright (c) Jupyter Development Team.
 // 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[]]>([
         [
           '# Title',
           [
diff --git a/packages/toc/tsconfig.json b/packages/toc/tsconfig.json
index d2a5e05732..bc9463e7c3 100644
--- a/packages/toc/tsconfig.json
+++ b/packages/toc/tsconfig.json
@@ -24,8 +24,11 @@
     {
       "path": "../translation"
     },
     {
       "path": "../ui-components"
+    },
+    {
+      "path": "../rendermime-interfaces"
     }
   ]
 }
diff --git a/packages/toc/tsconfig.test.json b/packages/toc/tsconfig.test.json
index c74d05d1b8..d1c57d511b 100644
--- a/packages/toc/tsconfig.test.json
+++ b/packages/toc/tsconfig.test.json
@@ -24,10 +24,13 @@
       "path": "../translation"
     },
     {
       "path": "../ui-components"
     },
+    {
+      "path": "../rendermime-interfaces"
+    },
     {
       "path": "."
     },
     {
       "path": "../testing"
diff --git a/yarn.lock b/yarn.lock
index ebfa291826..4aded5bf99 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4906,10 +4906,11 @@ __metadata:
     "@jupyterlab/apputils": ^4.2.0-beta.1
     "@jupyterlab/coreutils": ^6.1.0-beta.1
     "@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
     "@lumino/coreutils": ^2.1.2
     "@lumino/disposable": ^2.1.2

Vulnerable code: import { WidgetTracker } from '@jupyterlab/apputils';
  optional: [ILayoutRestorer, ISettingRegistry, ITableOfContentsRegistry],
  tocRegistry: ITableOfContentsRegistry | null
        rendermime.markdownParser
import { IMarkdownParser } from '@jupyterlab/rendermime';
    protected parser: IMarkdownParser | null
          heading.level
        const selector = `h${heading.level}[id="${elementId}"]`;
        const elementId = await getIdForHeading(heading, this.parser!);
          ? `h${heading.level}[id="${elementId}"]`
  parser: IRenderMime.IMarkdownParser
      heading.level
                mdHeading.level
            `h${heading.level}[id="${id}"]`
     * Render a markdown source.
     * @returns - A promise of the string.
  parser: IMarkdownParser,
  level: number
    const innerHTML = await parser.render(raw);
    if (!innerHTML) {
      return null;
    }
    const container = document.createElement('div');
    container.innerHTML = innerHTML;
    const header = container.querySelector(`h${level}`);
    return renderMarkdown.createHeaderId(header);
Fixed code: import { ISanitizer, WidgetTracker } from '@jupyterlab/apputils';
  IRenderMime,
  optional: [
    ILayoutRestorer,
    ISettingRegistry,
    ITableOfContentsRegistry,
    ISanitizer
  ],
  tocRegistry: ITableOfContentsRegistry | null,
  sanitizer: IRenderMime.ISanitizer | null
        rendermime.markdownParser,
        sanitizer ?? rendermime.sanitizer
import { IMarkdownParser, IRenderMime } from '@jupyterlab/rendermime';
    protected parser: IMarkdownParser | null,
    protected sanitizer: IRenderMime.ISanitizer
          heading.level,
          this.sanitizer
        const selector = `h${heading.level}[id="${CSS.escape(elementId)}"]`;
        const elementId = await getIdForHeading(
          heading,
          this.parser!,
          this.sanitizer
        );
          ? `h${heading.level}[id="${CSS.escape(elementId)}"]`
  parser: IRenderMime.IMarkdownParser,
  sanitizer: IRenderMime.ISanitizer
      heading.level,
      sanitizer
                mdHeading.level,
                this.rendermime.sanitizer
            `h${heading.level}[id="${CSS.escape(id)}"]`
     * Render a markdown source into unsanitized HTML.
     * @returns - A promise of the string containing HTML which may require sanitization.
    "@jupyterlab/rendermime-interfaces": "^3.9.0-beta.1",
import { Sanitizer } from '@jupyterlab/apputils';
import { IRenderMime } from '@jupyterlab/rendermime-interfaces';
 * @param sanitizer HTML sanitizer
  markdownParser: IMarkdownParser,
  level: number,
  sanitizer?: IRenderMime.ISanitizer
    const host = document.createElement('div');
    await renderMarkdown({
      markdownParser,
      host,
      source: raw,
      trusted: false,
      sanitizer: sanitizer ?? new Sanitizer(),
      shouldTypeset: false,
      resolver: null,
      linkHandler: null,
      latexTypesetter: null
    });
    const header = host.querySelector(`h${level}`);
    return header.id;
import { Sanitizer } from '@jupyterlab/apputils';
import { createMarkdownParser } from '@jupyterlab/markedparser-extension';
import { IMarkdownParser } from '@jupyterlab/rendermime';
import {
  EditorLanguageRegistry,
  IEditorLanguageRegistry
} from '@jupyterlab/codemirror';
    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);
      });
    });
    },
    {
      "path": "../rendermime-interfaces"
    {
      "path": "../rendermime-interfaces"
    },
    "@jupyterlab/rendermime-interfaces": ^3.9.0-beta.1