JupyterLab LaTeX typesetter links did not enforce `noopener` attribute
Description
jupyterlab is an extensible environment for interactive and reproducible computing, based on the Jupyter Notebook Architecture. Prior to version 4.4.8, links generated with LaTeX typesetters in Markdown files and Markdown cells in JupyterLab and Jupyter Notebook did not include the noopener attribute. This is deemed to have no impact on the default installations. Theoretically users of third-party LaTeX-rendering extensions could find themselves vulnerable to reverse tabnabbing attacks if links generated by those extensions included target=_blank (no such extensions are known at time of writing) and they were to click on a link generated in LaTeX (typically visibly different from other links). This issue has been patched in version 4.4.8.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
JupyterLab prior to 4.4.8 did not enforce noopener on LaTeX-rendered links, posing a theoretical reverse tabnabbing risk with third-party extensions.
Vulnerability
In JupyterLab versions before 4.4.8, links generated by LaTeX typesetters in Markdown files and cells lacked the noopener attribute. This is a security hardening oversight, as the official LaTeX extensions (jupyterlab-mathjax, jupyterlab-mathjax2, jupyterlab-katex) do not set target=_blank, so there is no impact on default installations [2][4].
Exploitation
For exploitation, a user would need to install a third-party LaTeX-rendering extension that produces links with target=_blank. No such extensions are known at the time of writing, making the attack theoretical [2][4]. If such an extension existed, clicking a LaTeX-generated link (which is visually distinct) could lead to a reverse tabnabbing attack, where the new page manipulates the previous tab.
Impact
Reverse tabnabbing could allow an attacker to spoof the original page, potentially leading to credential theft or other client-side attacks. However, given the absence of vulnerable extensions, the practical risk is minimal [2].
Mitigation
The issue is fixed in JupyterLab 4.4.8. The patch, visible in commit [3], modifies the rendering pipeline to call hardenAnchorLinks after LaTeX typesetting, enforcing noopener and target=_blank on all anchor elements. Users should upgrade to the latest version. No workarounds are necessary [4].
AI Insight generated on May 19, 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.4.8 | 4.4.8 |
Affected products
2- Range: <4.4.8
- jupyterlab/jupyterlabv5Range: < 4.4.8
Patches
188ef373039a8Merge commit from fork
13 files changed · +346 −39
.github/workflows/linuxjs-tests.yml+1 −0 modified@@ -38,6 +38,7 @@ jobs: js-logconsole, js-lsp, js-mainmenu, + js-mathjax-extension, js-mermaid, js-metadataform, js-metapackage,
packages/mathjax-extension/babel.config.js+6 −0 added@@ -0,0 +1,6 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +module.exports = require('@jupyterlab/testing/lib/babel-config');
packages/mathjax-extension/jest.config.js+7 −0 added@@ -0,0 +1,7 @@ +/* + * Copyright (c) Jupyter Development Team. + * Distributed under the terms of the Modified BSD License. + */ + +const func = require('@jupyterlab/testing/lib/jest-config'); +module.exports = func(__dirname);
packages/mathjax-extension/package.json+8 −0 modified@@ -38,7 +38,12 @@ ], "scripts": { "build": "tsc -b", + "build:test": "tsc --build tsconfig.test.json", "clean": "rimraf lib && rimraf tsconfig.tsbuildinfo", + "test": "jest", + "test:cov": "jest --collect-coverage", + "test:debug": "node --inspect-brk ../../node_modules/.bin/jest --runInBand", + "test:debug:watch": "node --inspect-brk ../../node_modules/.bin/jest --runInBand --watch", "eslint": "eslint . --ext .ts,.tsx --fix", "watch": "tsc -b --watch" }, @@ -50,6 +55,9 @@ "mathjax-full": "^3.2.2" }, "devDependencies": { + "@jupyterlab/testing": "^4.5.0-alpha.3", + "@types/jest": "^29.2.0", + "jest": "^29.2.0", "rimraf": "~5.0.5", "typescript": "~5.5.4" },
packages/mathjax-extension/src/index.ts+32 −0 modified@@ -68,6 +68,7 @@ export class MathJaxTypesetter implements ILatexTypesetter { this._mathDocument.options.elements = [node]; this._mathDocument.clear().render(); delete this._mathDocument.options.elements; + Private.hardenAnchorLinks(node); } protected _initialized: boolean = false; @@ -107,6 +108,14 @@ const mathJaxPlugin: JupyterFrontEndPlugin<ILatexTypesetter> = { const scale = args['scale'] || 1.0; md.outputJax.options.scale = scale; md.rerender(); + + // Harden only the re-rendered anchors + for (const math of md.math) { + const root = math.typesetRoot as HTMLElement | null; + if (root) { + Private.hardenAnchorLinks(root); + } + } }, label: args => trans.__('Mathjax Scale ') + @@ -204,4 +213,27 @@ namespace Private { return _loading.promise; } + + /** + * Utility function to harden anchor links in a given element + */ + export function hardenAnchorLinks(element: HTMLElement): void { + const anchors = element.querySelectorAll('a'); + anchors.forEach(anchor => { + // Add rel="noopener" if not already present + const existingRel = anchor.rel || ''; + const relValues = existingRel.split(/\s+/).filter(v => v.length > 0); + + if (!relValues.includes('noopener')) { + relValues.push('noopener'); + } + + anchor.rel = relValues.join(' '); + + // Add target="_blank" if not already present + if (anchor.target !== '_blank') { + anchor.target = '_blank'; + } + }); + } }
packages/mathjax-extension/test/plugin.spec.ts+62 −0 added@@ -0,0 +1,62 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. +import { CommandRegistry } from '@lumino/commands'; +import type { JupyterFrontEnd } from '@jupyterlab/application'; +import { ILatexTypesetter } from '@jupyterlab/rendermime'; +import plugin from '@jupyterlab/mathjax-extension'; + +describe('@jupyterlab/mathjax-extension', () => { + describe('plugin', () => { + let app: JupyterFrontEnd; + let typesetter: ILatexTypesetter; + + beforeEach(async () => { + app = { + commands: new CommandRegistry() + } as any; + typesetter = await plugin.activate(app); + }); + + describe('mathjax:scale', () => { + it('should scale the equation', async () => { + const host = document.createElement('div'); + host.innerHTML = '$1 + 1$'; + document.body.appendChild(host); + + // Render at initial scale + await typesetter.typeset(host); + let container = host.querySelector('mjx-container') as HTMLElement; + const sizeBefore = parseFloat(container.style.fontSize); + + // Increase the scale + await app.commands.execute('mathjax:scale', { scale: 2 }); + + // Measure size + container = host.querySelector('mjx-container') as HTMLElement; + const sizeAfter = parseFloat(container.style.fontSize); + + expect(sizeAfter).toBeGreaterThan(sizeBefore); + }); + + it.each([ + '$$\\href{https://jupyter.org}{1}$$', + '$\\href{https://jupyter.org}{1}$' + ])('should harden remote URLs in links', async input => { + const host = document.createElement('div'); + host.innerHTML = input; + document.body.appendChild(host); + + // Render at initial scale + await typesetter.typeset(host); + + // Re-render at a different scale + await app.commands.execute('mathjax:scale', { scale: 2 }); + + // Check that links are hardened + expect(host.innerHTML).toContain( + '<a href="https://jupyter.org" rel="noopener" target="_blank">' + ); + }); + }); + }); +});
packages/mathjax-extension/test/typesetter.spec.ts+43 −0 added@@ -0,0 +1,43 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { MathJaxTypesetter } from '@jupyterlab/mathjax-extension'; + +describe('@jupyterlab/mathjax-extension', () => { + describe('MathJaxTypesetter', () => { + let typesetter: MathJaxTypesetter; + beforeEach(() => { + typesetter = new MathJaxTypesetter(); + }); + describe('.typeset()', () => { + it('should typeset inline equations', async () => { + const host = document.createElement('div'); + host.innerHTML = '$1 + 1$'; + document.body.appendChild(host); + await typesetter.typeset(host); + expect(host.innerHTML).toContain('<mn>1</mn><mo>+</mo><mn>1</mn>'); + }); + + it('should typeset block equations', async () => { + const host = document.createElement('div'); + host.innerHTML = '$$1 + 1$$'; + document.body.appendChild(host); + await typesetter.typeset(host); + expect(host.innerHTML).toContain('<mn>1</mn><mo>+</mo><mn>1</mn>'); + }); + + it.each([ + '$$\\href{https://jupyter.org}{1}$$', + '$\\href{https://jupyter.org}{1}$' + ])('should harden remote URLs in links', async input => { + const host = document.createElement('div'); + host.innerHTML = input; + document.body.appendChild(host); + await typesetter.typeset(host); + expect(host.innerHTML).toContain( + '<a href="https://jupyter.org" rel="noopener" target="_blank">' + ); + }); + }); + }); +});
packages/mathjax-extension/tsconfig.test.json+21 −0 added@@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfigbase.test", + "include": ["src/*", "test/*"], + "references": [ + { + "path": "../application" + }, + { + "path": "../rendermime" + }, + { + "path": "../translation" + }, + { + "path": "." + }, + { + "path": "../testing" + } + ] +}
packages/rendermime-interfaces/src/index.ts+1 −1 modified@@ -556,7 +556,7 @@ export namespace IRenderMime { * @param element - the DOM element to typeset. The typesetting may * happen synchronously or asynchronously. */ - typeset(element: HTMLElement): void; + typeset(element: HTMLElement): void | Promise<void>; } /**
packages/rendermime/src/renderers.ts+53 −34 modified@@ -78,16 +78,24 @@ export async function renderHTML(options: renderHTML.IOptions): Promise<void> { } // Handle default behavior of nodes. - Private.handleDefaults(host, resolver); + Private.handleDefaults(host); + + if (shouldTypeset && latexTypesetter) { + const maybePromise = latexTypesetter.typeset(host); + if (maybePromise instanceof Promise) { + // Harden anchors to contain secure target/rel attributes. + maybePromise.then(() => hardenAnchorLinks(host, resolver)); + } else { + hardenAnchorLinks(host, resolver); + } + } else { + hardenAnchorLinks(host, resolver); + } // Patch the urls if a resolver is available. if (resolver) { await Private.handleUrls(host, resolver, linkHandler); } - - if (shouldTypeset && latexTypesetter) { - latexTypesetter.typeset(host); - } } /** @@ -253,7 +261,13 @@ export async function renderLatex( // Typeset the node if needed. if (shouldTypeset && latexTypesetter) { - latexTypesetter.typeset(host); + const maybePromise = latexTypesetter.typeset(host); + if (maybePromise instanceof Promise) { + // Harden anchors to contain secure target/rel attributes. + maybePromise.then(() => hardenAnchorLinks(host)); + } else { + hardenAnchorLinks(host); + } } } @@ -1126,6 +1140,38 @@ export namespace renderError { } } +/** + * Harden anchor links. + */ +export function hardenAnchorLinks( + node: HTMLElement, + resolver?: IRenderMime.IResolver | null +): void { + // Handle anchor elements. + const anchors = node.getElementsByTagName('a'); + for (let i = 0; i < anchors.length; i++) { + const el = anchors[i]; + // skip when processing a elements inside svg + // which are of type SVGAnimatedString + if (!(el instanceof HTMLAnchorElement)) { + continue; + } + const path = el.href; + const isLocal = + resolver && resolver.isLocal + ? resolver.isLocal(path) + : URLExt.isLocal(path); + // set target attribute if not already present + if (!el.target) { + el.target = isLocal ? '_self' : '_blank'; + } + // set rel as 'noopener' for non-local anchors + if (!isLocal) { + el.rel = 'noopener'; + } + } +} + /** * The namespace for module implementation details. */ @@ -1178,34 +1224,7 @@ namespace Private { /** * Handle the default behavior of nodes. */ - export function handleDefaults( - node: HTMLElement, - resolver?: IRenderMime.IResolver | null - ): void { - // Handle anchor elements. - const anchors = node.getElementsByTagName('a'); - for (let i = 0; i < anchors.length; i++) { - const el = anchors[i]; - // skip when processing a elements inside svg - // which are of type SVGAnimatedString - if (!(el instanceof HTMLAnchorElement)) { - continue; - } - const path = el.href; - const isLocal = - resolver && resolver.isLocal - ? resolver.isLocal(path) - : URLExt.isLocal(path); - // set target attribute if not already present - if (!el.target) { - el.target = isLocal ? '_self' : '_blank'; - } - // set rel as 'noopener' for non-local anchors - if (!isLocal) { - el.rel = 'noopener'; - } - } - + export function handleDefaults(node: HTMLElement): void { // Handle image elements. const imgs = node.getElementsByTagName('img'); for (let i = 0; i < imgs.length; i++) {
packages/rendermime/src/widgets.ts+27 −4 modified@@ -204,7 +204,7 @@ export class RenderedHTML extends RenderedHTMLCommon { this._rendered .then(() => { if (this.latexTypesetter) { - this.latexTypesetter.typeset(this.node); + Private.typeset(this.node, this.latexTypesetter, this.resolver); } }) .catch(console.warn); @@ -251,7 +251,7 @@ export class RenderedLatex extends RenderedCommon { this._rendered .then(() => { if (this.latexTypesetter) { - this.latexTypesetter.typeset(this.node); + Private.typeset(this.node, this.latexTypesetter, this.resolver); } }) .catch(console.warn); @@ -352,7 +352,7 @@ export class RenderedMarkdown extends RenderedHTMLCommon { this._rendered .then(() => { if (this.latexTypesetter) { - this.latexTypesetter.typeset(this.node); + Private.typeset(this.node, this.latexTypesetter, this.resolver); } }) .catch(console.warn); @@ -403,7 +403,7 @@ export class RenderedSVG extends RenderedCommon { this._rendered .then(() => { if (this.latexTypesetter) { - this.latexTypesetter.typeset(this.node); + Private.typeset(this.node, this.latexTypesetter, this.resolver); } }) .catch(console.warn); @@ -494,3 +494,26 @@ export class RenderedJavaScript extends RenderedCommon { }); } } + +/** + * The namespace for module implementation details. + */ +namespace Private { + /** + * Run typesetter on the given node and harden links. + */ + export function typeset( + host: HTMLElement, + latexTypesetter: IRenderMime.ILatexTypesetter, + resolver?: IRenderMime.IResolver | null + ) { + const result = latexTypesetter.typeset(host); + // Harden anchors to contain secure target/rel attributes. + if (result instanceof Promise) { + // If promised was returned, await for rendering to complete. + result.then(() => renderers.hardenAnchorLinks(host, resolver)); + } else { + renderers.hardenAnchorLinks(host, resolver); + } + } +}
packages/rendermime/test/factories.spec.ts+82 −0 modified@@ -420,6 +420,76 @@ describe('rendermime/factories', () => { expect.not.arrayContaining(['script']) ); }); + + it('should harden remote URLs', async () => { + const source = '<a href="https://jupyter.org">link</a>'; + const f = markdownRendererFactory; + const mimeType = 'text/markdown'; + const model = createModel(mimeType, source); + const w = f.createRenderer({ mimeType, ...defaultOptions }); + await w.renderModel(model); + expect(w.node.innerHTML).toBe( + '<pre><a href="https://jupyter.org" rel="noopener" target="_blank">link</a></pre>' + ); + }); + + it('should harden remote URLs introduced by latex typesetter', async () => { + const source = '$$\\href{https://jupyter.org}{link}$$'; + const f = markdownRendererFactory; + const mimeType = 'text/markdown'; + const model = createModel(mimeType, source); + const pretendLatexTypesetter: IRenderMime.ILatexTypesetter = { + typeset: (element: HTMLElement): void => { + element.innerHTML = ''; + const link = document.createElement('a'); + link.textContent = 'link'; + link.href = 'https://jupyter.org'; + element.appendChild(link); + } + }; + const w = f.createRenderer({ + mimeType, + ...defaultOptions, + latexTypesetter: pretendLatexTypesetter + }); + Widget.attach(w, document.body); + await w.renderModel(model); + expect(w.node.innerHTML).toBe( + '<a href="https://jupyter.org" target="_blank" rel="noopener">link</a>' + ); + w.dispose(); + }); + + it('should harden remote URLs introduced by async latex typesetter', async () => { + const source = '$$\\href{https://jupyter.org}{link}$$'; + const f = markdownRendererFactory; + const mimeType = 'text/markdown'; + const model = createModel(mimeType, source); + const pretendLatexTypesetter: IRenderMime.ILatexTypesetter = { + typeset: async (element: HTMLElement): Promise<void> => { + // Simulate slower type-setting. + await new Promise(resolve => window.setTimeout(resolve, 100)); + element.innerHTML = ''; + const link = document.createElement('a'); + link.textContent = 'link'; + link.href = 'https://jupyter.org'; + element.appendChild(link); + return; + } + }; + const w = f.createRenderer({ + mimeType, + ...defaultOptions, + latexTypesetter: pretendLatexTypesetter + }); + Widget.attach(w, document.body); + await w.renderModel(model); + await new Promise(resolve => window.setTimeout(resolve, 200)); + expect(w.node.innerHTML).toBe( + '<a href="https://jupyter.org" target="_blank" rel="noopener">link</a>' + ); + w.dispose(); + }); }); }); @@ -482,6 +552,18 @@ describe('rendermime/factories', () => { await w.renderModel(model); expect(w.node.innerHTML).toBe('<pre></pre>'); }); + + it('should harden remote URLs', async () => { + const source = '<a href="https://jupyter.org">link</a>'; + const f = htmlRendererFactory; + const mimeType = 'text/html'; + const model = createModel(mimeType, source); + const w = f.createRenderer({ mimeType, ...defaultOptions }); + await w.renderModel(model); + expect(w.node.innerHTML).toBe( + '<a href="https://jupyter.org" rel="noopener" target="_blank">link</a>' + ); + }); }); it('should sanitize html', async () => {
yarn.lock+3 −0 modified@@ -4065,8 +4065,11 @@ __metadata: dependencies: "@jupyterlab/application": ^4.5.0-alpha.4 "@jupyterlab/rendermime": ^4.5.0-alpha.4 + "@jupyterlab/testing": ^4.5.0-alpha.4 "@jupyterlab/translation": ^4.5.0-alpha.4 "@lumino/coreutils": ^2.2.1 + "@types/jest": ^29.2.0 + jest: ^29.2.0 mathjax-full: ^3.2.2 rimraf: ~5.0.5 typescript: ~5.5.4
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-vvfj-2jqx-52jmghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-59842ghsaADVISORY
- github.com/jupyterlab/jupyterlab/commit/88ef373039a8cc09f27d3814382a512d9033675cghsax_refsource_MISCWEB
- github.com/jupyterlab/jupyterlab/security/advisories/GHSA-vvfj-2jqx-52jmghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.