CVE-2024-22421: Relative Path Traversal (CWE-23)
value: 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.
CWE description: The product uses external input to construct a pathname that should be within a restricted directory, but it does not properly neutralize sequences such as ".." that can resolve to a location that is outside of that directory.
CVSS v3 base score: 6.5
Published: 2024-01-19T21:15Z
Repository: https://github.com/jupyterlab/jupyterlab
Language: TypeScript
Files changed: packages/apputils-extension/src/workspacesplugin.ts, packages/hub-extension/src/index.ts, packages/services/src/session/restapi.ts, packages/services/src/setting/index.ts, packages/services/src/terminal/restapi.ts, packages/services/src/workspace/index.ts, packages/services/test/session/session.spec.ts, packages/services/test/setting/manager.spec.ts, packages/services/test/workspace/manager.spec.ts, packages/translation/src/server.ts
Commit date: 2024-01-19 13:15:40 +0000
Commit message: Merge pull request from GHSA-44cc-43rp-5947

Co-authored-by: Frédéric Collonval <fcollonval@users.noreply.github.com>
Diff: diff --git a/packages/apputils-extension/src/workspacesplugin.ts b/packages/apputils-extension/src/workspacesplugin.ts
index c487446ab9..b6fac19e44 100644
--- a/packages/apputils-extension/src/workspacesplugin.ts
+++ b/packages/apputils-extension/src/workspacesplugin.ts
@@ -208,11 +208,15 @@ namespace Private {
 
         // Save last save location for the save command.
         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 {
           document.location.href = url;
         }
diff --git a/packages/hub-extension/src/index.ts b/packages/hub-extension/src/index.ts
index f90a6fc452..ab0e01c613 100644
--- a/packages/hub-extension/src/index.ts
+++ b/packages/hub-extension/src/index.ts
@@ -55,13 +55,20 @@ function activateHubExtension(
     hubHost: hubHost,
     hubPrefix: hubPrefix
   });
 
   // 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;
 
   commands.addCommand(CommandIDs.restart, {
     label: trans.__('Restart Server'),
diff --git a/packages/services/src/session/restapi.ts b/packages/services/src/session/restapi.ts
index b95a63f196..6198c28296 100644
--- a/packages/services/src/session/restapi.ts
+++ b/packages/services/src/session/restapi.ts
@@ -40,11 +40,16 @@ 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;
 }
 
 /**
  * Shut down a session by id.
  */
diff --git a/packages/services/src/setting/index.ts b/packages/services/src/setting/index.ts
index 7fd299fcaa..56422dce61 100644
--- a/packages/services/src/setting/index.ts
+++ b/packages/services/src/setting/index.ts
@@ -159,8 +159,13 @@ namespace Private {
    */
   export function url(base: string, id: string, idsOnly?: boolean): string {
     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}`;
   }
 }
diff --git a/packages/services/src/terminal/restapi.ts b/packages/services/src/terminal/restapi.ts
index 4b36c9bfaf..69eb9c01e0 100644
--- a/packages/services/src/terminal/restapi.ts
+++ b/packages/services/src/terminal/restapi.ts
@@ -99,11 +99,15 @@ export async function listRunning(
 export async function shutdownTerminal(
   name: string,
   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) {
     const data = await response.json();
     const msg =
diff --git a/packages/services/src/workspace/index.ts b/packages/services/src/workspace/index.ts
index 7adb969e11..f77c7cbe77 100644
--- a/packages/services/src/workspace/index.ts
+++ b/packages/services/src/workspace/index.ts
@@ -176,8 +176,13 @@ export namespace Workspace {
 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;
   }
 }
diff --git a/packages/services/test/session/session.spec.ts b/packages/services/test/session/session.spec.ts
index 282543e6d7..13b2f6ccca 100644
--- a/packages/services/test/session/session.spec.ts
+++ b/packages/services/test/session/session.spec.ts
@@ -142,7 +142,11 @@ describe('session', () => {
     it('should handle a 404 status', async () => {
       await expect(
         SessionAPI.shutdownSession(UUID.uuid4())
       ).resolves.not.toThrow();
     });
+
+    it('should reject invalid on invalid id', async () => {
+      await expect(SessionAPI.shutdownSession('../')).rejects.toThrow();
+    });
   });
 });
diff --git a/packages/services/test/setting/manager.spec.ts b/packages/services/test/setting/manager.spec.ts
index e6d318fb8b..853a6f2346 100644
--- a/packages/services/test/setting/manager.spec.ts
+++ b/packages/services/test/setting/manager.spec.ts
@@ -51,10 +51,19 @@ describe('setting', () => {
       it('should fetch settings for an extension', async () => {
         const id = '@jupyterlab/apputils-extension:themes';
 
         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()', () => {
       it('should save a setting', async () => {
         const id = '@jupyterlab/apputils-extension:themes';
@@ -62,8 +71,19 @@ describe('setting', () => {
         const raw = `{"theme": "${theme}"}`;
 
         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();
+      });
     });
   });
 });
diff --git a/packages/services/test/workspace/manager.spec.ts b/packages/services/test/workspace/manager.spec.ts
index c581dbb2e3..98a6fb362f 100644
--- a/packages/services/test/workspace/manager.spec.ts
+++ b/packages/services/test/workspace/manager.spec.ts
@@ -53,10 +53,19 @@ describe('workspace', () => {
 
         await manager.save(id, { data: {}, metadata: { id } });
         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()', () => {
       it('should fetch a workspace list supporting arbitrary IDs', async () => {
         const ids = ['foo', 'bar', 'baz', 'f/o/o', 'b/a/r', 'b/a/z'];
@@ -85,8 +94,17 @@ describe('workspace', () => {
 
         await manager.save(id, { data: {}, metadata: { id } });
         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();
+      });
     });
   });
 });
diff --git a/packages/translation/src/server.ts b/packages/translation/src/server.ts
index d50cd20f46..be4d27f5bd 100644
--- a/packages/translation/src/server.ts
+++ b/packages/translation/src/server.ts
@@ -25,11 +25,15 @@ export async function requestTranslationsAPI<T>(
 ): Promise<T> {
   // Make request to Jupyter API
   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);
   } catch (error) {
     throw new ServerConnection.NetworkError(error);

Vulnerable code:         const url = URLExt.join(this._application, 'workspaces', id);
  const restartUrl = hubServerName
    ? hubHost + URLExt.join(hubPrefix, 'spawn', hubUser, hubServerName)
    : hubHost + URLExt.join(hubPrefix, 'spawn');
  return URLExt.join(baseUrl, SESSION_SERVICE_URL, id);
    return `${URLExt.join(base, SERVICE_SETTINGS_URL, id)}${idsOnlyParam}`;
  const url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL, name);
    return URLExt.join(base, SERVICE_WORKSPACES_URL, id);
  const requestUrl = URLExt.join(settings.baseUrl, translationsUrl, locale);
Fixed code:         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');
        }
  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 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;
    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}`;
  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 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;

    it('should reject invalid on invalid id', async () => {
      await expect(SessionAPI.shutdownSession('../')).rejects.toThrow();
    });

      it('should reject on invalid id', async () => {
        const id = '../';

        const callback = async () => {
          await manager.fetch(id);
        };
        await expect(callback).rejects.toThrow();
      });

      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();
      });

      it('should reject on invalid id', async () => {
        const id = '../';

        const callback = async () => {
          await manager.fetch(id);
        };
        await expect(callback).rejects.toThrow();
      });

      it('should reject on invalid id', async () => {
        const id = '../';

        const callback = async () => {
          await manager.save(id, { data: {}, metadata: { id } });
        };
        await expect(callback).rejects.toThrow();
      });
  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');
  }