Potential authentication and CSRF tokens leak in JupyterLab
Description
JupyterLab is an extensible environment for interactive and reproducible computing, based on the Jupyter Notebook and Architecture. Users of JupyterLab who click on a malicious link may get their Authorization and XSRFToken tokens exposed to a third party when running an older jupyter-server version. JupyterLab versions 4.1.0b2, 4.0.11, and 3.6.7 are patched. No workaround has been identified, however users should ensure to upgrade jupyter-server to version 2.7.2 or newer which includes a redirect vulnerability fix.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
JupyterLab fails to validate constructed URLs, allowing token theft via crafted links on outdated jupyter-server versions.
Vulnerability
Overview
CVE-2024-22421 is an open redirect or path traversal vulnerability in JupyterLab's URL construction logic. The bug occurs in several functions — such as workspace navigation, session URL generation, and hub spawn URLs — where user-supplied identifiers are concatenated into base URLs without verifying that the resulting path stays within the expected scope. As shown in the commits [3][4], the fix adds a check that the final URL starts with the intended base path; before the fix, an attacker could craft an id containing ../ sequences to redirect the browser to an arbitrary external domain or to leak tokens.
Exploitation
Users must click a malicious link, typically sent via phishing or embedded in a notebook. The attack requires that the JupyterLab instance runs a version of jupyter-server older than 2.7.2, which does not prevent the redirection [1]. The crafted link causes JupyterLab to navigate to a third-party site controlled by the attacker, and because the request includes the user's Authorization and XSRFToken cookies, those credentials are exfiltrated in the HTTP Referer header or via other means [1]. No authentication is needed beyond the user already having a session.
Impact
Successful exploitation results in disclosure of the user's Authorization and XSRFToken tokens. An attacker who obtains these tokens can impersonate the victim, access the JupyterLab instance with the victim's privileges, and potentially modify or exfiltrate notebooks, data, or execute code within the user's server session [1].
Mitigation
The vulnerability is patched in JupyterLab versions 4.1.0b2, 4.0.11, and 3.6.7 [1]. Users who cannot upgrade JupyterLab immediately should ensure that jupyter-server is upgraded to version 2.7.2 or newer, which includes a redirect fix that blocks the token-leakage vector [1]. No workaround exists for older deployments that cannot upgrade either component.
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 |
jupyterlabPyPI | < 3.6.7 | 3.6.7 |
notebookPyPI | >= 7.0.0, < 7.0.7 | 7.0.7 |
Affected products
11- osv-coords10 versionspkg:bitnami/jupyter-base-notebookpkg:bitnami/jupyterlabpkg:bitnami/jupyter-notebookpkg:deb/ubuntu/jupyter-notebook@5.2.2-1ubuntu0.1?arch=source&distro=esm-apps/bionicpkg:deb/ubuntu/jupyter-notebook@6.0.3-2ubuntu0.1?arch=source&distro=focalpkg:deb/ubuntu/jupyter-notebook@6.4.12-2.2ubuntu1?arch=source&distro=noblepkg:deb/ubuntu/jupyter-notebook@6.4.13-2?arch=source&distro=oracularpkg:deb/ubuntu/jupyter-notebook@6.4.8-1ubuntu0.1?arch=source&distro=jammypkg:pypi/jupyterlabpkg:pypi/notebook
>= 7.0.0, < 7.0.7+ 9 more
- (no CPE)range: >= 7.0.0, < 7.0.7
- (no CPE)range: < 3.6.7
- (no CPE)range: >= 7.0.0, < 7.0.7
- (no CPE)range: >= 0
- (no CPE)range: >= 0
- (no CPE)range: >= 0
- (no CPE)range: >= 0
- (no CPE)range: >= 0
- (no CPE)range: >= 4.0.0, < 4.0.11
- (no CPE)range: >= 7.0.0, < 7.0.7
- jupyterlab/jupyterlabv5Range: < 3.6.7
Patches
319bd9b96cb2eMerge pull request from GHSA-44cc-43rp-5947
10 files changed · +85 −9
packages/apputils-extension/src/workspacesplugin.ts+5 −1 modified@@ -210,7 +210,11 @@ namespace Private { await this._state.save(LAST_SAVE_ID, path); // Navigate to new workspace. - const url = URLExt.join(this._application, 'workspaces', id); + const workspacesBase = URLExt.join(this._application, 'workspaces'); + const url = URLExt.join(workspacesBase, id); + if (!workspacesBase.startsWith(url)) { + throw new Error('Can only be used for workspaces'); + } if (this._router) { this._router.navigate(url, { hard: true }); } else {
packages/hub-extension/src/index.ts+10 −3 modified@@ -57,9 +57,16 @@ function activateHubExtension( }); // If hubServerName is set, use JupyterHub 1.0 URL. - const restartUrl = hubServerName - ? hubHost + URLExt.join(hubPrefix, 'spawn', hubUser, hubServerName) - : hubHost + URLExt.join(hubPrefix, 'spawn'); + const spawnBase = URLExt.join(hubPrefix, 'spawn'); + let restartUrl: string; + if (hubServerName) { + const suffix = URLExt.join(spawnBase, hubUser, hubServerName); + if (!suffix.startsWith(spawnBase)) { + throw new Error('Can only be used for spawn requests'); + } + restartUrl = hubHost + suffix; + } + restartUrl = hubHost + spawnBase; const { commands } = app;
packages/services/src/session/restapi.ts+6 −1 modified@@ -42,7 +42,12 @@ export async function listRunning( * Get a session url. */ export function getSessionUrl(baseUrl: string, id: string): string { - return URLExt.join(baseUrl, SESSION_SERVICE_URL, id); + const servicesBase = URLExt.join(baseUrl, SESSION_SERVICE_URL); + const result = URLExt.join(servicesBase, id); + if (!result.startsWith(servicesBase)) { + throw new Error('Can only be used for services requests'); + } + return result; } /**
packages/services/src/setting/index.ts+6 −1 modified@@ -161,6 +161,11 @@ namespace Private { const idsOnlyParam = idsOnly ? URLExt.objectToQueryString({ ids_only: true }) : ''; - return `${URLExt.join(base, SERVICE_SETTINGS_URL, id)}${idsOnlyParam}`; + const settingsBase = URLExt.join(base, SERVICE_SETTINGS_URL); + const result = URLExt.join(settingsBase, id); + if (!result.startsWith(settingsBase)) { + throw new Error('Can only be used for workspaces requests'); + } + return `${result}${idsOnlyParam}`; } }
packages/services/src/terminal/restapi.ts+5 −1 modified@@ -101,7 +101,11 @@ export async function shutdownTerminal( settings: ServerConnection.ISettings = ServerConnection.makeSettings() ): Promise<void> { Private.errorIfNotAvailable(); - const url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL, name); + const workspacesBase = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL); + const url = URLExt.join(workspacesBase, name); + if (!url.startsWith(workspacesBase)) { + throw new Error('Can only be used for terminal requests'); + } const init = { method: 'DELETE' }; const response = await ServerConnection.makeRequest(url, init, settings); if (response.status === 404) {
packages/services/src/workspace/index.ts+6 −1 modified@@ -178,6 +178,11 @@ namespace Private { * Get the url for a workspace. */ export function url(base: string, id: string): string { - return URLExt.join(base, SERVICE_WORKSPACES_URL, id); + const workspacesBase = URLExt.join(base, SERVICE_WORKSPACES_URL); + const result = URLExt.join(workspacesBase, id); + if (!result.startsWith(workspacesBase)) { + throw new Error('Can only be used for workspaces requests'); + } + return result; } }
packages/services/test/session/session.spec.ts+4 −0 modified@@ -144,5 +144,9 @@ describe('session', () => { SessionAPI.shutdownSession(UUID.uuid4()) ).resolves.not.toThrow(); }); + + it('should reject invalid on invalid id', async () => { + await expect(SessionAPI.shutdownSession('../')).rejects.toThrow(); + }); }); });
packages/services/test/setting/manager.spec.ts+20 −0 modified@@ -53,6 +53,15 @@ describe('setting', () => { expect((await manager.fetch(id)).id).toBe(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.fetch(id); + }; + await expect(callback).rejects.toThrow(); + }); }); describe('#save()', () => { @@ -64,6 +73,17 @@ describe('setting', () => { await manager.save(id, raw); expect(JSON.parse((await manager.fetch(id)).raw).theme).toBe(theme); }); + + it('should reject on invalid id', async () => { + const id = '../'; + const theme = 'Foo Theme'; + const raw = `{"theme": "${theme}"}`; + + const callback = async () => { + await manager.save(id, raw); + }; + await expect(callback).rejects.toThrow(); + }); }); }); });
packages/services/test/workspace/manager.spec.ts+18 −0 modified@@ -55,6 +55,15 @@ describe('workspace', () => { expect((await manager.fetch(id)).metadata.id).toBe(id); await manager.remove(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.fetch(id); + }; + await expect(callback).rejects.toThrow(); + }); }); describe('#list()', () => { @@ -87,6 +96,15 @@ describe('workspace', () => { expect((await manager.fetch(id)).metadata.id).toBe(id); await manager.remove(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.save(id, { data: {}, metadata: { id } }); + }; + await expect(callback).rejects.toThrow(); + }); }); }); });
packages/translation/src/server.ts+5 −1 modified@@ -27,7 +27,11 @@ export async function requestTranslationsAPI<T>( const settings = serverSettings ?? ServerConnection.makeSettings(); translationsUrl = translationsUrl || `${settings.appUrl}/${TRANSLATIONS_SETTINGS_URL}`; - const requestUrl = URLExt.join(settings.baseUrl, translationsUrl, locale); + const translationsBase = URLExt.join(settings.baseUrl, translationsUrl); + const requestUrl = URLExt.join(translationsBase, locale); + if (!requestUrl.startsWith(translationsBase)) { + throw new Error('Can only be used for translations requests'); + } let response: Response; try { response = await ServerConnection.makeRequest(requestUrl, init, settings);
1ef7a4fa0202Merge pull request from GHSA-44cc-43rp-5947
10 files changed · +85 −9
packages/apputils-extension/src/workspacesplugin.ts+5 −1 modified@@ -210,7 +210,11 @@ namespace Private { await this._state.save(LAST_SAVE_ID, path); // Navigate to new workspace. - const url = URLExt.join(this._application, 'workspaces', id); + const workspacesBase = URLExt.join(this._application, 'workspaces'); + const url = URLExt.join(workspacesBase, id); + if (!workspacesBase.startsWith(url)) { + throw new Error('Can only be used for workspaces'); + } if (this._router) { this._router.navigate(url, { hard: true }); } else {
packages/hub-extension/src/index.ts+10 −3 modified@@ -57,9 +57,16 @@ function activateHubExtension( }); // If hubServerName is set, use JupyterHub 1.0 URL. - const restartUrl = hubServerName - ? hubHost + URLExt.join(hubPrefix, 'spawn', hubUser, hubServerName) - : hubHost + URLExt.join(hubPrefix, 'spawn'); + const spawnBase = URLExt.join(hubPrefix, 'spawn'); + let restartUrl: string; + if (hubServerName) { + const suffix = URLExt.join(spawnBase, hubUser, hubServerName); + if (!suffix.startsWith(spawnBase)) { + throw new Error('Can only be used for spawn requests'); + } + restartUrl = hubHost + suffix; + } + restartUrl = hubHost + spawnBase; const { commands } = app;
packages/services/src/session/restapi.ts+6 −1 modified@@ -42,7 +42,12 @@ export async function listRunning( * Get a session url. */ export function getSessionUrl(baseUrl: string, id: string): string { - return URLExt.join(baseUrl, SESSION_SERVICE_URL, id); + const servicesBase = URLExt.join(baseUrl, SESSION_SERVICE_URL); + const result = URLExt.join(servicesBase, id); + if (!result.startsWith(servicesBase)) { + throw new Error('Can only be used for services requests'); + } + return result; } /**
packages/services/src/setting/index.ts+6 −1 modified@@ -161,6 +161,11 @@ namespace Private { const idsOnlyParam = idsOnly ? URLExt.objectToQueryString({ ids_only: true }) : ''; - return `${URLExt.join(base, SERVICE_SETTINGS_URL, id)}${idsOnlyParam}`; + const settingsBase = URLExt.join(base, SERVICE_SETTINGS_URL); + const result = URLExt.join(settingsBase, id); + if (!result.startsWith(settingsBase)) { + throw new Error('Can only be used for workspaces requests'); + } + return `${result}${idsOnlyParam}`; } }
packages/services/src/terminal/restapi.ts+5 −1 modified@@ -101,7 +101,11 @@ export async function shutdownTerminal( settings: ServerConnection.ISettings = ServerConnection.makeSettings() ): Promise<void> { Private.errorIfNotAvailable(); - const url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL, name); + const workspacesBase = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL); + const url = URLExt.join(workspacesBase, name); + if (!url.startsWith(workspacesBase)) { + throw new Error('Can only be used for terminal requests'); + } const init = { method: 'DELETE' }; const response = await ServerConnection.makeRequest(url, init, settings); if (response.status === 404) {
packages/services/src/workspace/index.ts+6 −1 modified@@ -178,6 +178,11 @@ namespace Private { * Get the url for a workspace. */ export function url(base: string, id: string): string { - return URLExt.join(base, SERVICE_WORKSPACES_URL, id); + const workspacesBase = URLExt.join(base, SERVICE_WORKSPACES_URL); + const result = URLExt.join(workspacesBase, id); + if (!result.startsWith(workspacesBase)) { + throw new Error('Can only be used for workspaces requests'); + } + return result; } }
packages/services/test/session/session.spec.ts+4 −0 modified@@ -144,5 +144,9 @@ describe('session', () => { SessionAPI.shutdownSession(UUID.uuid4()) ).resolves.not.toThrow(); }); + + it('should reject invalid on invalid id', async () => { + await expect(SessionAPI.shutdownSession('../')).rejects.toThrow(); + }); }); });
packages/services/test/setting/manager.spec.ts+20 −0 modified@@ -53,6 +53,15 @@ describe('setting', () => { expect((await manager.fetch(id)).id).toBe(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.fetch(id); + }; + await expect(callback).rejects.toThrow(); + }); }); describe('#save()', () => { @@ -64,6 +73,17 @@ describe('setting', () => { await manager.save(id, raw); expect(JSON.parse((await manager.fetch(id)).raw).theme).toBe(theme); }); + + it('should reject on invalid id', async () => { + const id = '../'; + const theme = 'Foo Theme'; + const raw = `{"theme": "${theme}"}`; + + const callback = async () => { + await manager.save(id, raw); + }; + await expect(callback).rejects.toThrow(); + }); }); }); });
packages/services/test/workspace/manager.spec.ts+18 −0 modified@@ -55,6 +55,15 @@ describe('workspace', () => { expect((await manager.fetch(id)).metadata.id).toBe(id); await manager.remove(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.fetch(id); + }; + await expect(callback).rejects.toThrow(); + }); }); describe('#list()', () => { @@ -87,6 +96,15 @@ describe('workspace', () => { expect((await manager.fetch(id)).metadata.id).toBe(id); await manager.remove(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.save(id, { data: {}, metadata: { id } }); + }; + await expect(callback).rejects.toThrow(); + }); }); }); });
packages/translation/src/server.ts+5 −1 modified@@ -27,7 +27,11 @@ export async function requestTranslationsAPI<T>( const settings = serverSettings ?? ServerConnection.makeSettings(); translationsUrl = translationsUrl || `${settings.appUrl}/${TRANSLATIONS_SETTINGS_URL}`; - const requestUrl = URLExt.join(settings.baseUrl, translationsUrl, locale); + const translationsBase = URLExt.join(settings.baseUrl, translationsUrl); + const requestUrl = URLExt.join(translationsBase, locale); + if (!requestUrl.startsWith(translationsBase)) { + throw new Error('Can only be used for translations requests'); + } let response: Response; try { response = await ServerConnection.makeRequest(requestUrl, init, settings);
fccd83dc4441Merge pull request from GHSA-44cc-43rp-5947
10 files changed · +85 −9
packages/apputils-extension/src/workspacesplugin.ts+5 −1 modified@@ -218,7 +218,11 @@ namespace Private { await this._state.save(LAST_SAVE_ID, path); // Navigate to new workspace. - const url = URLExt.join(this._application, 'workspaces', id); + const workspacesBase = URLExt.join(this._application, 'workspaces'); + const url = URLExt.join(workspacesBase, id); + if (!workspacesBase.startsWith(url)) { + throw new Error('Can only be used for workspaces'); + } if (this._router) { this._router.navigate(url, { hard: true }); } else {
packages/hub-extension/src/index.ts+10 −3 modified@@ -56,9 +56,16 @@ function activateHubExtension( }); // If hubServerName is set, use JupyterHub 1.0 URL. - const restartUrl = hubServerName - ? hubHost + URLExt.join(hubPrefix, 'spawn', hubUser, hubServerName) - : hubHost + URLExt.join(hubPrefix, 'spawn'); + const spawnBase = URLExt.join(hubPrefix, 'spawn'); + let restartUrl: string; + if (hubServerName) { + const suffix = URLExt.join(spawnBase, hubUser, hubServerName); + if (!suffix.startsWith(spawnBase)) { + throw new Error('Can only be used for spawn requests'); + } + restartUrl = hubHost + suffix; + } + restartUrl = hubHost + spawnBase; const { commands } = app;
packages/services/src/session/restapi.ts+6 −1 modified@@ -42,7 +42,12 @@ export async function listRunning( * Get a session url. */ export function getSessionUrl(baseUrl: string, id: string): string { - return URLExt.join(baseUrl, SESSION_SERVICE_URL, id); + const servicesBase = URLExt.join(baseUrl, SESSION_SERVICE_URL); + const result = URLExt.join(servicesBase, id); + if (!result.startsWith(servicesBase)) { + throw new Error('Can only be used for services requests'); + } + return result; } /**
packages/services/src/setting/index.ts+6 −1 modified@@ -149,6 +149,11 @@ namespace Private { * Get the url for a plugin's settings. */ export function url(base: string, id: string): string { - return URLExt.join(base, SERVICE_SETTINGS_URL, id); + const settingsBase = URLExt.join(base, SERVICE_SETTINGS_URL); + const result = URLExt.join(settingsBase, id); + if (!result.startsWith(settingsBase)) { + throw new Error('Can only be used for workspaces requests'); + } + return result; } }
packages/services/src/terminal/restapi.ts+5 −1 modified@@ -101,7 +101,11 @@ export async function shutdownTerminal( settings: ServerConnection.ISettings = ServerConnection.makeSettings() ): Promise<void> { Private.errorIfNotAvailable(); - const url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL, name); + const workspacesBase = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL); + const url = URLExt.join(workspacesBase, name); + if (!url.startsWith(workspacesBase)) { + throw new Error('Can only be used for terminal requests'); + } const init = { method: 'DELETE' }; const response = await ServerConnection.makeRequest(url, init, settings); if (response.status === 404) {
packages/services/src/workspace/index.ts+6 −1 modified@@ -178,6 +178,11 @@ namespace Private { * Get the url for a workspace. */ export function url(base: string, id: string): string { - return URLExt.join(base, SERVICE_WORKSPACES_URL, id); + const workspacesBase = URLExt.join(base, SERVICE_WORKSPACES_URL); + const result = URLExt.join(workspacesBase, id); + if (!result.startsWith(workspacesBase)) { + throw new Error('Can only be used for workspaces requests'); + } + return result; } }
packages/services/test/session/session.spec.ts+4 −0 modified@@ -157,5 +157,9 @@ describe('session', () => { it('should handle a 404 status', () => { return SessionAPI.shutdownSession(UUID.uuid4()); }); + + it('should reject invalid on invalid id', async () => { + await expect(SessionAPI.shutdownSession('../')).rejects.toThrow(); + }); }); });
packages/services/test/setting/manager.spec.ts+20 −0 modified@@ -53,6 +53,15 @@ describe('setting', () => { expect((await manager.fetch(id)).id).toBe(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.fetch(id); + }; + await expect(callback).rejects.toThrow(); + }); }); describe('#save()', () => { @@ -64,6 +73,17 @@ describe('setting', () => { await manager.save(id, raw); expect(JSON.parse((await manager.fetch(id)).raw).theme).toBe(theme); }); + + it('should reject on invalid id', async () => { + const id = '../'; + const theme = 'Foo Theme'; + const raw = `{"theme": "${theme}"}`; + + const callback = async () => { + await manager.save(id, raw); + }; + await expect(callback).rejects.toThrow(); + }); }); }); });
packages/services/test/workspace/manager.spec.ts+18 −0 modified@@ -55,6 +55,15 @@ describe('workspace', () => { expect((await manager.fetch(id)).metadata.id).toBe(id); await manager.remove(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.fetch(id); + }; + await expect(callback).rejects.toThrow(); + }); }); describe('#list()', () => { @@ -87,6 +96,15 @@ describe('workspace', () => { expect((await manager.fetch(id)).metadata.id).toBe(id); await manager.remove(id); }); + + it('should reject on invalid id', async () => { + const id = '../'; + + const callback = async () => { + await manager.save(id, { data: {}, metadata: { id } }); + }; + await expect(callback).rejects.toThrow(); + }); }); }); });
packages/translation/src/server.ts+5 −1 modified@@ -27,7 +27,11 @@ export async function requestTranslationsAPI<T>( const settings = serverSettings ?? ServerConnection.makeSettings(); translationsUrl = translationsUrl || `${settings.appUrl}/${TRANSLATIONS_SETTINGS_URL}/`; - const requestUrl = URLExt.join(settings.baseUrl, translationsUrl, locale); + const translationsBase = URLExt.join(settings.baseUrl, translationsUrl); + const requestUrl = URLExt.join(translationsBase, locale); + if (!requestUrl.startsWith(translationsBase)) { + throw new Error('Can only be used for translations requests'); + } let response: Response; try { response = await ServerConnection.makeRequest(requestUrl, init, settings);
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-44cc-43rp-5947ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-22421ghsaADVISORY
- github.com/jupyterlab/jupyterlab/commit/19bd9b96cb2e77170a67e43121637d0b5619e8c6ghsax_refsource_MISCWEB
- github.com/jupyterlab/jupyterlab/commit/1ef7a4fa0202ebdf663e1cc0b45c8813a34a0b96ghsaWEB
- github.com/jupyterlab/jupyterlab/commit/fccd83dc4441da0384ee3fd1322c3b2d9ad4caaaghsaWEB
- github.com/jupyterlab/jupyterlab/security/advisories/GHSA-44cc-43rp-5947ghsax_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
1- Top 10 web hacking techniques of 2024: nominations openPortSwigger Research · Jan 8, 2025